Skip to content

Commit 8266a99

Browse files
MengJitAaravMalani
andauthored
Wire autocomplete and syntax highlighting to ace editor (#3828)
* feat(conductor): wire autocomplete and syntax highlighting to ace editor Set up the conductor autocomplete plugin host side, including per-evaluator syntax highlighting via SyntaxHighlightData. Fixes startup highlighting by listening to editor session swaps (changeSession) and reapplying the correct ace mode. Eliminates duplicate conductor preload race by consolidating preload to a single saga handler. Co-authored-by: Aarav Malani <aarav.malani@gmail.com> * chore: update yarn.lock for @sourceacademy/autocomplete dependency * Update src/commons/sagas/WorkspaceSaga/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * chore * ci: retrigger lint check * style: fix import sort order * resolve merge conflict * ci: retrigger lint check * chore * Remove comment * Remove comment * Add type qualifier Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(editor): guard acequire against missing mode module * fix(editor): guard acequire against missing mode module * Organize imports post-merge --------- Co-authored-by: Aarav Malani <aarav.malani@gmail.com>
1 parent c2fd62d commit 8266a99

9 files changed

Lines changed: 386 additions & 21 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@octokit/rest": "^22.0.0",
4343
"@reduxjs/toolkit": "^1.9.7",
4444
"@sentry/react": "^10.5.0",
45+
"@sourceacademy/autocomplete": "github:source-academy/autocomplete#e669d9ed98753350a3c8433a92985227eb789663",
4546
"@sourceacademy/c-slang": "^1.0.21",
4647
"@sourceacademy/conductor": "https://github.com/source-academy/conductor.git#0.4.0",
4748
"@sourceacademy/language-directory": "https://github.com/source-academy/language-directory.git#0.0.6",

src/commons/editor/Editor.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import { AceMouseEvent, HighlightedLines, Position } from './EditorTypes';
3636
// TODO: Should further refactor into EditorBase + different variants.
3737
// Ideally, hooks should be specified by the parent component instead.
3838
import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types';
39+
import { flagConductorEnable } from 'src/features/conductor/flagConductorEnable';
3940
import { ExternalLibraryName } from '../application/types/ExternalTypes';
41+
import { useFeature } from '../featureFlags/useFeature';
42+
import { useTypedSelector } from '../utils/Hooks';
4043
import useHighlighting from './UseHighlighting';
4144
import useNavigation from './UseNavigation';
4245
import useRefactor from './UseRefactor';
@@ -72,6 +75,7 @@ type EditorStateProps = {
7275
sourceVariant?: Variant;
7376
hooks?: EditorHook[];
7477
editorBinding?: EditorBinding;
78+
mode?: string;
7579
setUsers?: React.Dispatch<React.SetStateAction<Record<string, SharedbAceUser>>>;
7680
// TODO: Handle changing of external library
7781
updateLanguageCallback?: (sublanguage: SALanguage, e: any) => void;
@@ -471,6 +475,67 @@ const EditorBase = memo((props: EditorProps & LocalStateProps) => {
471475
}
472476
};
473477

478+
const conductorEnabled = useFeature(flagConductorEnable);
479+
const selectedEvaluatorId = useTypedSelector(s => s.languageDirectory.selectedEvaluatorId)!;
480+
useEffect(() => {
481+
if (!conductorEnabled || !reactAceRef.current || !selectedEvaluatorId) {
482+
return;
483+
}
484+
485+
const editor = reactAceRef.current.editor;
486+
const modeId = `ace/mode/${selectedEvaluatorId}`;
487+
let modeChangeUnsub: (() => void) | undefined;
488+
let pollHandle: ReturnType<typeof setInterval> | undefined;
489+
490+
const apply = (session: any) => {
491+
let modeModule: any;
492+
try {
493+
modeModule = acequire(modeId);
494+
} catch {
495+
return false;
496+
}
497+
if (!modeModule?.Mode) return false;
498+
if ((session.getMode() as any).$id === modeId) return true;
499+
session.setMode(new modeModule.Mode());
500+
return true;
501+
};
502+
503+
const attachToSession = (session: any) => {
504+
modeChangeUnsub?.();
505+
if (pollHandle) clearInterval(pollHandle);
506+
507+
const onChangeMode = () => {
508+
if ((session.getMode() as any).$id !== modeId) {
509+
apply(session);
510+
}
511+
};
512+
session.on('changeMode', onChangeMode);
513+
modeChangeUnsub = () => session.off('changeMode', onChangeMode);
514+
515+
if (!apply(session)) {
516+
pollHandle = setInterval(() => {
517+
if (apply(session)) {
518+
clearInterval(pollHandle);
519+
pollHandle = undefined;
520+
}
521+
}, 200);
522+
}
523+
};
524+
525+
attachToSession(editor.getSession());
526+
527+
const onChangeSession = (e: any) => {
528+
attachToSession(e.session);
529+
};
530+
editor.on('changeSession', onChangeSession);
531+
532+
return () => {
533+
modeChangeUnsub?.();
534+
if (pollHandle) clearInterval(pollHandle);
535+
editor.off('changeSession', onChangeSession);
536+
};
537+
}, [conductorEnabled, selectedEvaluatorId]);
538+
474539
const aceEditorProps: IAceEditorProps = {
475540
className: 'react-ace',
476541
editorProps: {
@@ -480,7 +545,9 @@ const EditorBase = memo((props: EditorProps & LocalStateProps) => {
480545
fontSize: 17,
481546
height: '100%',
482547
highlightActiveLine: false,
483-
mode: getModeString(sourceChapter, sourceVariant, externalLibraryName),
548+
mode: conductorEnabled
549+
? 'text'
550+
: getModeString(sourceChapter, sourceVariant, externalLibraryName),
484551
theme: 'source',
485552
value: props.editorValue,
486553
width: '100%',

src/commons/sagas/LanguageDirectorySaga.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { ILanguageDefinition } from '@sourceacademy/language-directory/dist/types';
22
import { getEvaluatorDefinition } from '@sourceacademy/language-directory/dist/util';
33
import { call, fork, put, select } from 'redux-saga/effects';
4+
import { selectConductorEnable } from 'src/features/conductor/flagConductorEnable';
45
import { selectDirectoryLanguageUrl } from 'src/features/directory/flagDirectoryLanguageUrl';
56

67
import LanguageDirectoryActions from '../../features/directory/LanguageDirectoryActions';
78
import { LanguageDirectoryState } from '../../features/directory/LanguageDirectoryTypes';
89
import type { OverallState } from '../application/ApplicationTypes';
910
import { combineSagaHandlers } from '../redux/utils';
11+
import { preloadConductorEvaluatorSaga } from './helpers/conductorEvaluatorCache';
1012

1113
export function* getLanguageDefinitionSaga() {
1214
const directory: LanguageDirectoryState = yield select(
@@ -48,6 +50,18 @@ const languageDirectoryHandlers = combineSagaHandlers({
4850
if (language.evaluators.length > 0) {
4951
yield put(LanguageDirectoryActions.setSelectedEvaluator(language.evaluators[0].id));
5052
}
53+
54+
const conductorEnabled: boolean = yield select(selectConductorEnable);
55+
if (!conductorEnabled) return;
56+
57+
const evaluator = yield call(getEvaluatorDefinitionSaga);
58+
if (!evaluator?.path) return;
59+
60+
try {
61+
yield call(preloadConductorEvaluatorSaga, evaluator.path);
62+
} catch (error) {
63+
console.error('Failed to preload:', error);
64+
}
5165
}
5266
});
5367

src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ import { makeCCompilerConfig, specialCReturnObject } from '../../../../commons/u
1818
import { javaRun } from '../../../../commons/utils/JavaHelper';
1919
import { EventType } from '../../../../features/achievement/AchievementTypes';
2020
import type { BrowserHostPlugin } from '../../../../features/conductor/BrowserHostPlugin';
21-
import { createConductor } from '../../../../features/conductor/createConductor';
2221
import { selectConductorEnable } from '../../../../features/conductor/flagConductorEnable';
23-
import { selectConductorEvaluatorUrl } from '../../../../features/conductor/flagConductorEvaluatorUrl';
2422
import LanguageDirectoryActions from '../../../../features/directory/LanguageDirectoryActions';
2523
import StoriesActions from '../../../../features/stories/StoriesActions';
2624
import { type OverallState } from '../../../application/ApplicationTypes';
@@ -31,6 +29,7 @@ import { showWarningMessage } from '../../../utils/notifications/NotificationsHe
3129
import { makeExternalBuiltins as makeSourcerorExternalBuiltins } from '../../../utils/SourcerorHelper';
3230
import WorkspaceActions from '../../../workspace/WorkspaceActions';
3331
import { EVAL_SILENT, type WorkspaceLocation } from '../../../workspace/WorkspaceTypes';
32+
import { getPreparedConductorSaga } from '../../helpers/conductorEvaluatorCache';
3433
import { getEvaluatorDefinitionSaga } from '../../LanguageDirectorySaga';
3534
import { selectStoryEnv, selectWorkspace } from '../../SafeEffects';
3635
import { dumpDisplayBuffer } from './dumpDisplayBuffer';
@@ -560,8 +559,7 @@ function* handleStatuses(
560559
/**
561560
* Runs code using the evaluators in the Language Directory using the Conductor framework.
562561
* Invoked when the conductor.enable feature flag is enabled.
563-
* Fetches the evaluator from the URL specified in the language directory and creates a Conductor instance
564-
* to load the evaluator and run the code in a web worker.
562+
* Uses a preloaded Conductor instance when available to reduce startup latency.
565563
*/
566564
export function* evalCodeConductorSaga(
567565
files: Record<string, string>,
@@ -585,21 +583,11 @@ export function* evalCodeConductorSaga(
585583
evaluator = yield call(getEvaluatorDefinitionSaga);
586584
if (!evaluator?.path) throw Error('no evaluator');
587585
}
588-
const overrideEvaluatorPath: string = (yield select(selectConductorEvaluatorUrl))?.trim?.() ?? '';
589-
const path: string = overrideEvaluatorPath || evaluator.path;
590586

591-
// Download evaluator code
592-
const evaluatorResponse: Response = yield call(fetch, path);
593-
if (!evaluatorResponse.ok) throw Error("can't get evaluator");
594-
const evaluatorBlob: Blob = yield call([evaluatorResponse, 'blob']);
595-
const url: string = yield call(URL.createObjectURL, evaluatorBlob);
596-
597-
// Create Conductor instance ith the evaluator
587+
// Reuse a preloaded conductor instance when available.
598588
const { hostPlugin, conduit }: { hostPlugin: BrowserHostPlugin; conduit: IConduit } = yield call(
599-
createConductor,
600-
url,
601-
async (fileName: string) => files[fileName],
602-
(pluginName: string) => {} // TODO: implement dynamic plugin loading
589+
getPreparedConductorSaga,
590+
{ files, consume: true }
603591
);
604592

605593
// Begin evaluation
@@ -631,7 +619,6 @@ export function* evalCodeConductorSaga(
631619
//yield put(actions.debuggerReset(workspaceLocation));
632620
yield put(actions.endInterruptExecution(workspaceLocation));
633621
console.log('killed');
634-
yield call(URL.revokeObjectURL, url);
635622
}
636623

637624
// Special module errors

src/commons/sagas/WorkspaceSaga/index.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import { AutoCompleteEntry } from '@sourceacademy/autocomplete';
2+
import type { IConduit } from '@sourceacademy/conductor/conduit';
13
import type { FSModule } from 'browserfs/dist/node/core/FS';
24
import { type Context, findDeclaration, getNames } from 'js-slang';
35
import { Chapter, Variant } from 'js-slang/dist/langs';
46
import Phaser from 'phaser';
5-
import { call, put, select } from 'redux-saga/effects';
7+
import type { EventChannel } from 'redux-saga';
8+
import { call, put, select, take } from 'redux-saga/effects';
9+
import { race } from 'redux-saga/effects';
10+
import { delay } from 'redux-saga/effects';
11+
import AutoCompletePlugin from 'src/features/conductor/AutocompletePlugin';
12+
import { BrowserHostPlugin } from 'src/features/conductor/BrowserHostPlugin';
613

714
import InterpreterActions from '../../../commons/application/actions/InterpreterActions';
815
import { combineSagaHandlers } from '../../../commons/redux/utils';
@@ -33,6 +40,7 @@ import {
3340
showWarningMessage
3441
} from '../../utils/notifications/NotificationsHelper';
3542
import { showFullJSDisclaimer, showFullTSDisclaimer } from '../../utils/WarningDialogHelper';
43+
import { getPreparedConductorSaga } from '../helpers/conductorEvaluatorCache';
3644
import { selectWorkspace } from '../SafeEffects';
3745
import { evalCodeSaga } from './helpers/evalCode';
3846
import { evalEditorSaga } from './helpers/evalEditor';
@@ -148,7 +156,6 @@ const WorkspaceSaga = combineSagaHandlers({
148156
externalLibrary: extLib,
149157
programPrependValue: prepend
150158
} = yield* selectWorkspace(workspaceLocation);
151-
152159
const editorValue = editorTabs[activeEditorTabIndex ?? 0].value;
153160

154161
// Deal with prepended code
@@ -161,6 +168,49 @@ const WorkspaceSaga = combineSagaHandlers({
161168
autocompleteCode = prepend + '\n' + editorValue;
162169
}
163170

171+
if (yield select(selectConductorEnable)) {
172+
const { conduit }: { hostPlugin: BrowserHostPlugin; conduit: IConduit } =
173+
yield call(getPreparedConductorSaga);
174+
175+
const plugin = conduit.lookupPlugin('__autocomplete_plugin_web') as AutoCompletePlugin;
176+
if (plugin) {
177+
const channel: EventChannel<AutoCompleteEntry[]> = yield call(
178+
[plugin, 'complete'],
179+
autocompleteCode,
180+
action.payload.row + prependLength,
181+
action.payload.column
182+
);
183+
//const names: AutoCompleteEntry[] = yield take(channel);
184+
const { names, timeout }: { names?: AutoCompleteEntry[]; timeout?: true } = yield race({
185+
names: take(channel),
186+
timeout: delay(3000)
187+
});
188+
189+
if (timeout || !names) {
190+
console.warn('autocomplete channel timed out — runner never replied');
191+
channel.close();
192+
return;
193+
}
194+
195+
yield call(
196+
action.payload.callback,
197+
null,
198+
names.map(name => ({
199+
meta: name.meta,
200+
value: name.name,
201+
caption: name.name,
202+
docHTML: name.docHTML,
203+
score: name.score ? name.score + 1000 : 1000, // Prioritize suggestions from code
204+
name: undefined
205+
}))
206+
);
207+
channel.close();
208+
} else {
209+
yield call(action.payload.callback, null, []);
210+
}
211+
return;
212+
}
213+
164214
const [editorNames, displaySuggestions]: Awaited<ReturnType<typeof getNames>> = yield call(
165215
getNames,
166216
autocompleteCode,

0 commit comments

Comments
 (0)