From 2cd130e441b7f7d9fd4b9c87ff4647d21f720ba7 Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Mon, 15 Jun 2026 17:24:10 +0530 Subject: [PATCH 1/2] feat: add tab manager --- package.json | 2 + .../AssessmentWorkspace.tsx | 14 ++- .../mobileWorkspace/MobileWorkspace.tsx | 12 ++- .../mobileSideContent/MobileSideContent.tsx | 4 +- src/commons/sagas/AchievementSaga.ts | 4 +- src/commons/sagas/SideContentSaga.ts | 7 +- .../sagas/WorkspaceSaga/helpers/evalCode.ts | 4 +- .../sagas/helpers/conductorEvaluatorCache.ts | 24 ++++- src/commons/sideContent/SideContent.tsx | 6 +- src/commons/sideContent/SideContentActions.ts | 12 +-- src/commons/sideContent/SideContentHelper.ts | 5 +- src/commons/sideContent/SideContentManager.ts | 93 +++++++++++++++++++ .../sideContent/SideContentProvider.tsx | 19 ++-- src/commons/sideContent/SideContentTypes.ts | 12 ++- src/features/conductor/Registry.ts | 11 +++ src/features/conductor/TestPlugin.ts | 7 ++ .../subcomponents/GradingWorkspace.tsx | 5 +- src/pages/playground/PlaygroundTabs.tsx | 5 +- yarn.lock | 47 +++++++++- 19 files changed, 243 insertions(+), 50 deletions(-) create mode 100644 src/commons/sideContent/SideContentManager.ts create mode 100644 src/features/conductor/Registry.ts create mode 100644 src/features/conductor/TestPlugin.ts diff --git a/package.json b/package.json index a7a873dbf9..bc5c0e5a04 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,13 @@ "@sentry/react": "^10.5.0", "@sourceacademy/autocomplete": "github:source-academy/autocomplete#e669d9ed98753350a3c8433a92985227eb789663", "@sourceacademy/c-slang": "^1.0.21", + "@sourceacademy/common-tabs": "file:../plugins/src/common/tabs", "@sourceacademy/conductor": "https://github.com/source-academy/conductor.git#0.4.0", "@sourceacademy/language-directory": "https://github.com/source-academy/language-directory.git#0.0.6", "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.2", "@sourceacademy/sharedb-ace": "2.1.1", "@sourceacademy/sling-client": "^0.1.0", + "@sourceacademy/web-test": "file:..\\plugins\\src\\web\\test", "@szhsin/react-menu": "^4.0.0", "@tanstack/react-query": "^5.100.14", "@tanstack/react-table": "^8.9.3", diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index 31c617cb5f..3fab06d69b 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -72,7 +72,11 @@ import SideContentToneMatrix from '../sideContent/content/SideContentToneMatrix' import type { SideContentProps } from '../sideContent/SideContent'; import { changeSideContentHeight } from '../sideContent/SideContentActions'; import { useSideContent } from '../sideContent/SideContentHelper'; -import { type SideContentTab, SideContentType } from '../sideContent/SideContentTypes'; +import { + type SideContentTab, + type SideContentTabId, + SideContentType, +} from '../sideContent/SideContentTypes'; import Constants from '../utils/Constants'; import { useResponsive, useTypedSelector } from '../utils/Hooks'; import { assessmentTypeLink } from '../utils/ParamParseHelper'; @@ -666,8 +670,8 @@ function AssessmentWorkspace(props: AssessmentWorkspaceProps) { } const onChangeTabs = ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { if (newTabId === prevTabId) { @@ -879,8 +883,8 @@ function AssessmentWorkspace(props: AssessmentWorkspaceProps) { const mobileSideContentProps: (q: number) => MobileSideContentProps = (questionId: number) => { const onChangeTabs = ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { if (newTabId === prevTabId) { diff --git a/src/commons/mobileWorkspace/MobileWorkspace.tsx b/src/commons/mobileWorkspace/MobileWorkspace.tsx index fda1473735..e3124d8c3d 100644 --- a/src/commons/mobileWorkspace/MobileWorkspace.tsx +++ b/src/commons/mobileWorkspace/MobileWorkspace.tsx @@ -11,7 +11,11 @@ import McqChooser, { type McqChooserProps } from '../mcqChooser/McqChooser'; import { Prompt } from '../ReactRouterPrompt'; import type { ReplProps } from '../repl/Repl'; import type { SideBarTab } from '../sideBar/SideBar'; -import { type SideContentTab, SideContentType } from '../sideContent/SideContentTypes'; +import { + type SideContentTab, + type SideContentTabId, + SideContentType, +} from '../sideContent/SideContentTypes'; import DraggableRepl from './DraggableRepl'; import MobileKeyboard from './MobileKeyboard'; import MobileSideContent, { @@ -169,7 +173,7 @@ function MobileWorkspace(props: MobileWorkspaceProps) { const handleEditorEval = props.editorContainerProps?.handleEditorEval; const handleTabChangeForRepl = useCallback( - (newTabId: SideContentType, prevTabId: SideContentType) => { + (newTabId: SideContentTabId, prevTabId: SideContentTabId) => { // Evaluate program upon pressing the run tab. if (newTabId === SideContentType.mobileEditorRun) { handleEditorEval?.(); @@ -210,8 +214,8 @@ function MobileWorkspace(props: MobileWorkspaceProps) { const onChange = props.mobileSideContentProps.onChange; const onSideContentTabChange = useCallback( ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { onChange(newTabId, prevTabId, event); diff --git a/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx b/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx index edf05c6116..0a99967472 100644 --- a/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx +++ b/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx @@ -10,7 +10,7 @@ import { type ChangeTabsCallback, type SideContentLocation, type SideContentTab, - SideContentType, + type SideContentTabId, } from '../../sideContent/SideContentTypes'; import { propsAreEqual } from '../../utils/MemoizeHelper'; import MobileControlBar from './MobileControlBar'; @@ -66,7 +66,7 @@ function MobileSideContent({ * renderedPanels is not memoized since a change in selectedTabId (when changing tabs) * would force React.useMemo to recompute the nullary function anyway */ - const renderedPanels = (dynamicTabs: SideContentTab[], selectedTabId?: SideContentType) => { + const renderedPanels = (dynamicTabs: SideContentTab[], selectedTabId?: SideContentTabId) => { // TODO: Fix the CSS of all the panels (e.g. subst_visualizer) const renderPanel = (tab: SideContentTab, workspaceLocation?: SideContentLocation) => { if (!tab.body) return; diff --git a/src/commons/sagas/AchievementSaga.ts b/src/commons/sagas/AchievementSaga.ts index 8160900ace..01b9221de5 100644 --- a/src/commons/sagas/AchievementSaga.ts +++ b/src/commons/sagas/AchievementSaga.ts @@ -10,7 +10,7 @@ import type { Tokens } from '../application/types/SessionTypes'; import { combineSagaHandlers } from '../redux/utils'; import SideContentActions from '../sideContent/SideContentActions'; import { getLocation } from '../sideContent/SideContentHelper'; -import { SideContentType } from '../sideContent/SideContentTypes'; +import { type SideContentTabId, SideContentType } from '../sideContent/SideContentTypes'; import { actions } from '../utils/ActionsHelper'; import Constants from '../utils/Constants'; import { selectTokens } from './BackendSaga'; @@ -145,7 +145,7 @@ const AchievementSaga = combineSagaHandlers({ (state: OverallState) => state.session.enableAchievements, ); if (workspaceLocation !== undefined && eventNames.find(e => e === EventType.ERROR)) { - const selectedTab: SideContentType | undefined = yield select((state: OverallState) => { + const selectedTab: SideContentTabId | undefined = yield select((state: OverallState) => { const [loc] = getLocation(workspaceLocation); return state.sideContent[loc].selectedTab; }); diff --git a/src/commons/sagas/SideContentSaga.ts b/src/commons/sagas/SideContentSaga.ts index ea252067cd..393dfc9f76 100644 --- a/src/commons/sagas/SideContentSaga.ts +++ b/src/commons/sagas/SideContentSaga.ts @@ -7,6 +7,7 @@ import { getLocation } from '../sideContent/SideContentHelper'; import { type SideContentLocation, type SideContentManagerState, + type SideContentTabId, SideContentType, } from '../sideContent/SideContentTypes'; import WorkspaceActions from '../workspace/WorkspaceActions'; @@ -25,7 +26,7 @@ const isVisitSideContent = ( const selectSelectedTab = ( state: any, workspaceLocation: SideContentLocation, -): SideContentType | undefined => { +): SideContentTabId | undefined => { const sideContentState = (state.sideContent ?? state) as SideContentManagerState; const [location] = getLocation(workspaceLocation); return sideContentState[location]?.selectedTab; @@ -38,7 +39,7 @@ const SideContentSaga = combineSagaHandlers({ // When a program finishes evaluation, we clear all alerts, // So we must wait until after and all module tabs have been spawned // to process any kind of alerts that were raised by non-module side content - const selectedTab: SideContentType | undefined = yield select((state: any) => + const selectedTab: SideContentTabId | undefined = yield select((state: any) => selectSelectedTab(state, workspaceLocation), ); @@ -72,7 +73,7 @@ const SideContentSaga = combineSagaHandlers({ return; } - const selectedTabAfterWait: SideContentType | undefined = yield select((state: any) => + const selectedTabAfterWait: SideContentTabId | undefined = yield select((state: any) => selectSelectedTab(state, workspaceLocation), ); diff --git a/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts b/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts index 2c62ac78ae..86d67d37a2 100644 --- a/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts +++ b/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts @@ -22,6 +22,7 @@ import { selectConductorEnable } from '../../../../features/conductor/flagConduc import LanguageDirectoryActions from '../../../../features/directory/LanguageDirectoryActions'; import { type OverallState } from '../../../application/ApplicationTypes'; import { visitSideContent } from '../../../sideContent/SideContentActions'; +import sideContentManager from '../../../sideContent/SideContentManager'; import { SideContentType } from '../../../sideContent/SideContentTypes'; import { actions } from '../../../utils/ActionsHelper'; import DisplayBufferService from '../../../utils/DisplayBufferService'; @@ -548,7 +549,7 @@ export function* evalCodeConductorSaga( // Reuse a preloaded conductor instance when available. const { hostPlugin, conduit }: { hostPlugin: BrowserHostPlugin; conduit: IConduit } = yield call( getPreparedConductorSaga, - { files, consume: true }, + { files, consume: true, workspaceLocation }, ); // Begin evaluation @@ -574,6 +575,7 @@ export function* evalCodeConductorSaga( } yield cancel(statusTask); yield call([conduit, 'terminate']); + yield call([sideContentManager, sideContentManager.clearTabs]); yield cancel(stdoutTask); yield cancel(resultTask); yield cancel(errorTask); diff --git a/src/commons/sagas/helpers/conductorEvaluatorCache.ts b/src/commons/sagas/helpers/conductorEvaluatorCache.ts index 0436741c48..423da5c031 100644 --- a/src/commons/sagas/helpers/conductorEvaluatorCache.ts +++ b/src/commons/sagas/helpers/conductorEvaluatorCache.ts @@ -1,9 +1,12 @@ import type { IConduit } from '@sourceacademy/conductor/conduit'; import type { SagaIterator } from 'redux-saga'; import { call } from 'redux-saga/effects'; +import { registry } from 'src/features/conductor/Registry'; import type { BrowserHostPlugin } from '../../../features/conductor/BrowserHostPlugin'; import { createConductor } from '../../../features/conductor/createConductor'; +import sideContentManager from '../../sideContent/SideContentManager'; +import type { SideContentLocation } from '../../sideContent/SideContentTypes'; type PreparedConductor = { path: string; @@ -16,6 +19,7 @@ type PreparedConductor = { type GetPreparedConductorOptions = { files?: Record; consume?: boolean; + workspaceLocation?: SideContentLocation; }; let preparedConductorPath: string | null = null; @@ -40,6 +44,7 @@ async function terminatePreparedConductor(conductor: PreparedConductor | null) { } await conductor.conduit.terminate(); + sideContentManager.clearTabs(); URL.revokeObjectURL(conductor.evaluatorUrl); } @@ -61,9 +66,19 @@ async function createPreparedConductor(path: string): Promise const { hostPlugin, conduit } = createConductor( evaluatorUrl, async (fileName: string) => currentFiles[fileName], - (_pluginName: string) => { - // TODO: implement dynamic plugin loading - }, + (pluginName: string) => { + if (registry.has(pluginName)) { + const pluginClass = registry.get(pluginName)!; + conduit.registerPlugin(pluginClass, sideContentManager); + return; + } + // const pluginDefinition = useTypedSelector(s => s.pluginDirectory.pluginMap[pluginName]); + // if (!pluginDefinition) { + // throw new Error(`Plugin ${pluginName} not found in plugin directory`); + // } + // const pluginClass = pluginDefinition.resolutions.web; + // selectConductorEnable + } ); return { @@ -125,6 +140,9 @@ export function* getPreparedConductorSaga( } const path = currentEvaluatorPath; + if (options?.workspaceLocation) { + sideContentManager.setWorkspaceLocation(options.workspaceLocation); + } const prepared: PreparedConductor = yield call(ensurePreparedConductorSaga, path); const files = options?.files; const consume = options?.consume ?? false; diff --git a/src/commons/sideContent/SideContent.tsx b/src/commons/sideContent/SideContent.tsx index f759051823..8d411e3353 100644 --- a/src/commons/sideContent/SideContent.tsx +++ b/src/commons/sideContent/SideContent.tsx @@ -8,7 +8,7 @@ import type { ChangeTabsCallback, SideContentLocation, SideContentTab, - SideContentType, + SideContentTabId, } from './SideContentTypes'; export type SideContentProps = { @@ -19,8 +19,8 @@ export type SideContentProps = { afterDynamicTabs: SideContentTab[]; }; onChange?: ChangeTabsCallback; - selectedTabId?: SideContentType; - defaultTab?: SideContentType; + selectedTabId?: SideContentTabId; + defaultTab?: SideContentTabId; workspaceLocation: SideContentLocation; }; diff --git a/src/commons/sideContent/SideContentActions.ts b/src/commons/sideContent/SideContentActions.ts index f420830e36..1cf1abbece 100644 --- a/src/commons/sideContent/SideContentActions.ts +++ b/src/commons/sideContent/SideContentActions.ts @@ -1,22 +1,22 @@ import { createActions } from '../redux/utils'; import type { DebuggerContext, WorkspaceLocation } from '../workspace/WorkspaceTypes'; -import type { SideContentLocation, SideContentType } from './SideContentTypes'; +import type { SideContentLocation, SideContentTabId } from './SideContentTypes'; const SideContentActions = createActions('sideContent', { - beginAlertSideContent: (id: SideContentType, workspaceLocation: SideContentLocation) => ({ + beginAlertSideContent: (id: SideContentTabId, workspaceLocation: SideContentLocation) => ({ id, workspaceLocation, }), - endAlertSideContent: (id: SideContentType, workspaceLocation: SideContentLocation) => ({ + endAlertSideContent: (id: SideContentTabId, workspaceLocation: SideContentLocation) => ({ id, workspaceLocation, }), visitSideContent: ( - newId: SideContentType, - prevId: SideContentType | undefined, + newId: SideContentTabId, + prevId: SideContentTabId | undefined, workspaceLocation: SideContentLocation, ) => ({ newId, prevId, workspaceLocation }), - removeSideContentAlert: (id: SideContentType, workspaceLocation: SideContentLocation) => ({ + removeSideContentAlert: (id: SideContentTabId, workspaceLocation: SideContentLocation) => ({ id, workspaceLocation, }), diff --git a/src/commons/sideContent/SideContentHelper.ts b/src/commons/sideContent/SideContentHelper.ts index d99512a5b9..aae2d309c1 100644 --- a/src/commons/sideContent/SideContentHelper.ts +++ b/src/commons/sideContent/SideContentHelper.ts @@ -21,6 +21,7 @@ import type { SideContentLocation, SideContentState, SideContentTab, + SideContentTabId, } from './SideContentTypes'; import { SideContentType } from './SideContentTypes'; @@ -72,14 +73,14 @@ export const getTabId = (tab: SideContentTab) => export const generateTabAlert = (shouldAlert: boolean) => `side-content-tooltip${shouldAlert ? ' side-content-tab-alert' : ''}`; -export const useSideContent = (location: SideContentLocation, defaultTab?: SideContentType) => { +export const useSideContent = (location: SideContentLocation, defaultTab?: SideContentTabId) => { const [workspaceLocation] = getLocation(location); const { alerts, dynamicTabs, selectedTab, height }: SideContentState = useTypedSelector( state => state.sideContent[workspaceLocation], ); const dispatch = useDispatch(); const setSelectedTab = useCallback( - (newId: SideContentType) => { + (newId: SideContentTabId) => { if ( (selectedTab === SideContentType.substVisualizer || selectedTab === SideContentType.cseMachine) && diff --git a/src/commons/sideContent/SideContentManager.ts b/src/commons/sideContent/SideContentManager.ts new file mode 100644 index 0000000000..9c5ccc33f6 --- /dev/null +++ b/src/commons/sideContent/SideContentManager.ts @@ -0,0 +1,93 @@ +import type { ITabService, Tab } from '@sourceacademy/common-tabs'; + +import type { SideContentLocation, SideContentTab } from './SideContentTypes'; + +type Listener = () => void; + +type RegisteredTab = { + tab: SideContentTab; + visible: boolean; +}; + +export class TabService implements ITabService { + private readonly emptyTabs: SideContentTab[] = []; + private readonly listeners = new Set(); + private readonly tabs = new Map(); + private visibleTabs: SideContentTab[] = []; + private workspaceLocation: SideContentLocation = 'playground'; + + registerTab(tab: Tab): void { + const currentTab = this.tabs.get(tab.id); + this.tabs.set(tab.id, { + tab, + visible: currentTab?.visible ?? false, + }); + this.emit(); + } + + unregisterTab(id: string): void { + if (!this.tabs.delete(id)) { + return; + } + this.emit(); + } + + showTab(id: string): void { + this.setTabVisibility(id, true); + } + + hideTab(id: string): void { + this.setTabVisibility(id, false); + } + + clearTabs(): void { + if (this.tabs.size === 0) { + return; + } + this.tabs.clear(); + this.emit(); + } + + getTabs(workspaceLocation: SideContentLocation): SideContentTab[] { + if (workspaceLocation !== this.workspaceLocation) { + return this.emptyTabs; + } + return this.visibleTabs; + } + + setWorkspaceLocation(workspaceLocation: SideContentLocation): void { + if (this.workspaceLocation === workspaceLocation) { + return; + } + this.workspaceLocation = workspaceLocation; + this.emit(); + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(): void { + this.visibleTabs = Array.from(this.tabs.values()) + .filter(({ visible }) => visible) + .map(({ tab }) => tab); + this.listeners.forEach(listener => listener()); + } + + private setTabVisibility(id: string, visible: boolean): void { + const currentTab = this.tabs.get(id); + if (!currentTab || currentTab.visible === visible) { + return; + } + this.tabs.set(id, { + ...currentTab, + visible, + }); + this.emit(); + } +} + +const sideContentManager = new TabService(); + +export default sideContentManager; diff --git a/src/commons/sideContent/SideContentProvider.tsx b/src/commons/sideContent/SideContentProvider.tsx index 859165b97c..2a079b8b1c 100644 --- a/src/commons/sideContent/SideContentProvider.tsx +++ b/src/commons/sideContent/SideContentProvider.tsx @@ -1,11 +1,12 @@ -import { useCallback } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { useSideContent } from './SideContentHelper'; +import sideContentManager from './SideContentManager'; import type { ChangeTabsCallback, SideContentLocation, SideContentTab, - SideContentType, + SideContentTabId, } from './SideContentTypes'; type SideContentProviderProps = { @@ -18,7 +19,7 @@ type SideContentProviderProps = { alerts: string[]; changeTabsCallback: ChangeTabsCallback; height?: number; - selectedTab?: SideContentType; + selectedTab?: SideContentTabId; }) => React.ReactElement; /** @@ -26,12 +27,12 @@ type SideContentProviderProps = { * then responsible for managing tab changing */ onChange?: ChangeTabsCallback; - selectedTab?: SideContentType; + selectedTab?: SideContentTabId; /** * Value to use if the currently selected tab is undefined */ - defaultTab?: SideContentType; + defaultTab?: SideContentTabId; workspaceLocation: SideContentLocation; }; @@ -56,10 +57,14 @@ export default function SideContentProvider({ workspaceLocation, defaultTab, ); + const serviceTabs = useSyncExternalStore( + sideContentManager.subscribe.bind(sideContentManager), + () => sideContentManager.getTabs(workspaceLocation), + ); const allTabs = tabs - ? [...tabs.beforeDynamicTabs, ...dynamicTabs, ...tabs.afterDynamicTabs] - : dynamicTabs; + ? [...tabs.beforeDynamicTabs, ...dynamicTabs, ...serviceTabs, ...tabs.afterDynamicTabs] + : [...dynamicTabs, ...serviceTabs]; const changeTabsCallback: ChangeTabsCallback = useCallback( (newId, oldId, event) => { diff --git a/src/commons/sideContent/SideContentTypes.ts b/src/commons/sideContent/SideContentTypes.ts index 531717cc0e..870114fb1f 100644 --- a/src/commons/sideContent/SideContentTypes.ts +++ b/src/commons/sideContent/SideContentTypes.ts @@ -37,6 +37,8 @@ export enum SideContentType { upload = 'upload', } +export type SideContentTabId = SideContentType | string; + /** * @property label A string that will appear as the tooltip. * @@ -57,7 +59,7 @@ export type SideContentTab = { label: string; iconName: IconName; body: React.ReactElement | null; - id?: SideContentType; + id?: SideContentTabId; disabled?: boolean; }; @@ -93,12 +95,12 @@ export type SideContentState = { height?: number; dynamicTabs: SideContentTab[]; alerts: string[]; - selectedTab?: SideContentType; + selectedTab?: SideContentTabId; }; export type ChangeTabsCallback = ( - newId: SideContentType, - oldId: SideContentType, + newId: SideContentTabId, + oldId: SideContentTabId, event: React.MouseEvent, ) => void; @@ -106,5 +108,5 @@ export type SideContentDispatchProps = { /** * Call this function to cause the icon of the tab with the provided ID to flash */ - alertSideContent: (newId: SideContentType) => void; + alertSideContent: (newId: SideContentTabId) => void; }; diff --git a/src/features/conductor/Registry.ts b/src/features/conductor/Registry.ts new file mode 100644 index 0000000000..5d2ac2a051 --- /dev/null +++ b/src/features/conductor/Registry.ts @@ -0,0 +1,11 @@ +import type { ITabService } from '@sourceacademy/common-tabs'; +import type { PluginClass } from '@sourceacademy/conductor/conduit'; + +import AutoCompletePlugin from './AutocompletePlugin'; +import { TestPlugin } from './TestPlugin'; + +export type PluginRegistry = Map>; + +export const registry: PluginRegistry = new Map(); +registry.set('__autocomplete_plugin_web', AutoCompletePlugin); +registry.set('__web_test', TestPlugin); diff --git a/src/features/conductor/TestPlugin.ts b/src/features/conductor/TestPlugin.ts new file mode 100644 index 0000000000..699f00c4f6 --- /dev/null +++ b/src/features/conductor/TestPlugin.ts @@ -0,0 +1,7 @@ +import { TestPlugin as TestPluginAbstractClass } from '@sourceacademy/web-test'; +export class TestPlugin extends TestPluginAbstractClass { + constructor(...args: ConstructorParameters) { + console.log('TestPlugin constructor called with args:', args); + super(...args); + } +} diff --git a/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx b/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx index 964aef234d..801337564c 100644 --- a/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx +++ b/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx @@ -38,6 +38,7 @@ import type { SideContentProps } from '../../../../commons/sideContent/SideConte import { useSideContent } from '../../../../commons/sideContent/SideContentHelper'; import { type SideContentTab, + type SideContentTabId, SideContentType, } from '../../../../commons/sideContent/SideContentTypes'; import Workspace, { type WorkspaceProps } from '../../../../commons/workspace/Workspace'; @@ -425,8 +426,8 @@ function GradingWorkspace(props: Props) { const sideContentProps: SideContentProps = { onChange: ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { if (newTabId === prevTabId) { diff --git a/src/pages/playground/PlaygroundTabs.tsx b/src/pages/playground/PlaygroundTabs.tsx index 7496ea8539..1d2ae958a9 100644 --- a/src/pages/playground/PlaygroundTabs.tsx +++ b/src/pages/playground/PlaygroundTabs.tsx @@ -8,14 +8,15 @@ import SideContentSubstVisualizer from 'src/commons/sideContent/content/SideCont import { type SideContentLocation, type SideContentTab, + type SideContentTabId, SideContentType, } from 'src/commons/sideContent/SideContentTypes'; -export const mobileOnlyTabIds: readonly SideContentType[] = [ +export const mobileOnlyTabIds: readonly SideContentTabId[] = [ SideContentType.mobileEditor, SideContentType.mobileEditorRun, ]; -export const desktopOnlyTabIds: readonly SideContentType[] = [SideContentType.introduction]; +export const desktopOnlyTabIds: readonly SideContentTabId[] = [SideContentType.introduction]; export const makeIntroductionTabFrom = (content: string): SideContentTab => ({ label: 'Introduction', diff --git a/yarn.lock b/yarn.lock index 139ff3abeb..50fae9c5fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1400,6 +1400,24 @@ __metadata: languageName: node linkType: hard +"@blueprintjs/icons@npm:>=6.0.0": + version: 6.11.0 + resolution: "@blueprintjs/icons@npm:6.11.0" + dependencies: + change-case: "npm:^4.1.2" + classnames: "npm:^2.3.1" + tslib: "npm:~2.6.2" + peerDependencies: + "@types/react": 18 || 19 + react: 18 || 19 + react-dom: 18 || 19 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/192f1f3b6465f3c76eed1ea8192255e98fd32a0da6645419934478c5966441f31a1760e14c707f0124cdd70f8076b04d9efb9ad46c9ab1682702e17ed42ee11b + languageName: node + linkType: hard + "@blueprintjs/icons@npm:^6.0.0, @blueprintjs/icons@npm:^6.10.0": version: 6.10.0 resolution: "@blueprintjs/icons@npm:6.10.0" @@ -3155,6 +3173,16 @@ __metadata: languageName: node linkType: hard +"@sourceacademy/common-tabs@file:../plugins/src/common/tabs::locator=frontend%40workspace%3A.": + version: 0.0.1 + resolution: "@sourceacademy/common-tabs@file:../plugins/src/common/tabs#../plugins/src/common/tabs::hash=a8f273&locator=frontend%40workspace%3A." + dependencies: + "@blueprintjs/icons": "npm:>=6.0.0" + "@types/react": "npm:^19.2.17" + checksum: 10c0/b6e0c62a2643511e24cb58372f253477b2b221fd8496f42e434554a81258c643b44a5b9bcb0deb57e0c87127f1605537a324af5e7edb0bcac020a4777ebf3705 + languageName: node + linkType: hard + "@sourceacademy/conductor@https://github.com/source-academy/conductor.git#0.4.0": version: 0.4.0 resolution: "@sourceacademy/conductor@https://github.com/source-academy/conductor.git#commit=00a0d5fdce269d62a0feb33230300db27121be05" @@ -3198,6 +3226,17 @@ __metadata: languageName: node linkType: hard +"@sourceacademy/web-test@file:..\\plugins\\src\\web\\test::locator=frontend%40workspace%3A.": + version: 0.0.1 + resolution: "@sourceacademy/web-test@file:../plugins/src/web/test#../plugins/src/web/test::hash=191a7d&locator=frontend%40workspace%3A." + dependencies: + "@types/react": "npm:^19.2.17" + react: "npm:^19.2.7" + react-dom: "npm:^19.2.7" + checksum: 10c0/ece5b81f4b1e0b344373daf3f290341a096390753d8375579c4ed6fd8c5a319e6e409d7a2d06c3e2cfdfe43457595effa765037c28fec81417b8f8366a2adf84 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -4111,7 +4150,7 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^19.1.8": +"@types/react@npm:*, @types/react@npm:^19.1.8, @types/react@npm:^19.2.17": version: 19.2.17 resolution: "@types/react@npm:19.2.17" dependencies: @@ -7218,11 +7257,13 @@ __metadata: "@sentry/react": "npm:^10.5.0" "@sourceacademy/autocomplete": "github:source-academy/autocomplete#e669d9ed98753350a3c8433a92985227eb789663" "@sourceacademy/c-slang": "npm:^1.0.21" + "@sourceacademy/common-tabs": "file:../plugins/src/common/tabs" "@sourceacademy/conductor": "https://github.com/source-academy/conductor.git#0.4.0" "@sourceacademy/language-directory": "https://github.com/source-academy/language-directory.git#0.0.6" "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.2" "@sourceacademy/sharedb-ace": "npm:2.1.1" "@sourceacademy/sling-client": "npm:^0.1.0" + "@sourceacademy/web-test": "file:..\\plugins\\src\\web\\test" "@svgr/webpack": "npm:^8.0.0" "@swc/core": "npm:^1.11.22" "@szhsin/react-menu": "npm:^4.0.0" @@ -11113,7 +11154,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^19.2.4": +"react-dom@npm:^19.2.4, react-dom@npm:^19.2.7": version: 19.2.7 resolution: "react-dom@npm:19.2.7" dependencies: @@ -11468,7 +11509,7 @@ __metadata: languageName: node linkType: hard -"react@npm:^19.2.4": +"react@npm:^19.2.4, react@npm:^19.2.7": version: 19.2.7 resolution: "react@npm:19.2.7" checksum: 10c0/0bd0e2f1bbd4ba97561c6597bf8a5fec05e6476fe61e165c1065598d16668efc6715205599c94d3ddd49d36cb0f21cbf1b9bcc18ee840b805ce222c3e8d558ac From c2179dddb4be065ecfa463db8d769909b601922b Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Tue, 16 Jun 2026 00:42:09 +0530 Subject: [PATCH 2/2] feat: load external plugins from plugin directory --- .../sagas/helpers/conductorEvaluatorCache.ts | 45 +++++++++++++++---- src/commons/sideContent/SideContentHelper.ts | 6 ++- src/features/conductor/TestPlugin.ts | 7 +-- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/commons/sagas/helpers/conductorEvaluatorCache.ts b/src/commons/sagas/helpers/conductorEvaluatorCache.ts index 423da5c031..54bad7a1a4 100644 --- a/src/commons/sagas/helpers/conductorEvaluatorCache.ts +++ b/src/commons/sagas/helpers/conductorEvaluatorCache.ts @@ -1,10 +1,14 @@ import type { IConduit } from '@sourceacademy/conductor/conduit'; +import { PluginType } from '@sourceacademy/plugin-directory'; import type { SagaIterator } from 'redux-saga'; -import { call } from 'redux-saga/effects'; +import { call, select } from 'redux-saga/effects'; +import { requireProvider } from 'src/commons/sideContent/SideContentHelper'; import { registry } from 'src/features/conductor/Registry'; +import { selectDirectoryPluginUrl } from 'src/features/directory/flagDirectoryPluginUrl'; import type { BrowserHostPlugin } from '../../../features/conductor/BrowserHostPlugin'; import { createConductor } from '../../../features/conductor/createConductor'; +import type { OverallState } from '../../application/ApplicationTypes'; import sideContentManager from '../../sideContent/SideContentManager'; import type { SideContentLocation } from '../../sideContent/SideContentTypes'; @@ -27,6 +31,17 @@ let preparedConductor: PreparedConductor | null = null; let loadingConductorPath: string | null = null; let loadingConductorPromise: Promise | null = null; let currentEvaluatorPath: string | null = null; +let currentPluginDirectoryUrl: string | null = null; +let currentPluginMap: OverallState['pluginDirectory']['pluginMap'] = {}; + +function getWebPluginLocation(pluginName: string): string | undefined { + return currentPluginMap[pluginName]?.resolutions[PluginType.WEB]; +} + +function* updatePluginDirectorySnapshotSaga(): SagaIterator { + currentPluginMap = yield select((state: OverallState) => state.pluginDirectory.pluginMap); + currentPluginDirectoryUrl = yield select(selectDirectoryPluginUrl); +} async function fetchEvaluatorObjectUrl(path: string): Promise { const evaluatorResponse = await fetch(path); @@ -66,19 +81,29 @@ async function createPreparedConductor(path: string): Promise const { hostPlugin, conduit } = createConductor( evaluatorUrl, async (fileName: string) => currentFiles[fileName], - (pluginName: string) => { + async (pluginName: string) => { if (registry.has(pluginName)) { const pluginClass = registry.get(pluginName)!; conduit.registerPlugin(pluginClass, sideContentManager); return; } - // const pluginDefinition = useTypedSelector(s => s.pluginDirectory.pluginMap[pluginName]); - // if (!pluginDefinition) { - // throw new Error(`Plugin ${pluginName} not found in plugin directory`); - // } - // const pluginClass = pluginDefinition.resolutions.web; - // selectConductorEnable - } + + let pluginClassLocation = getWebPluginLocation(pluginName); + if (!pluginClassLocation) { + console.warn(`No web plugin resolution found for "${pluginName}" in the plugin directory.`); + return; + } + if (!pluginClassLocation.startsWith('http')) { + pluginClassLocation = new URL( + pluginClassLocation, + currentPluginDirectoryUrl || document.baseURI, + ).toString(); + } + await import(/* webpackIgnore: true */ pluginClassLocation) + .then(tab => tab.default(requireProvider)) + .then(plugin => conduit.registerPlugin(plugin, sideContentManager)) + .catch(error => console.error(`Unable to load external plugin "${pluginName}".`, error)); + }, ); return { @@ -124,6 +149,7 @@ export function* preloadConductorEvaluatorSaga(path?: string): SagaIterator { return; } + yield call(updatePluginDirectorySnapshotSaga); currentEvaluatorPath = path; yield call(ensurePreparedConductorSaga, path); } @@ -140,6 +166,7 @@ export function* getPreparedConductorSaga( } const path = currentEvaluatorPath; + yield call(updatePluginDirectorySnapshotSaga); if (options?.workspaceLocation) { sideContentManager.setWorkspaceLocation(options.workspaceLocation); } diff --git a/src/commons/sideContent/SideContentHelper.ts b/src/commons/sideContent/SideContentHelper.ts index aae2d309c1..206699c3e9 100644 --- a/src/commons/sideContent/SideContentHelper.ts +++ b/src/commons/sideContent/SideContentHelper.ts @@ -25,7 +25,7 @@ import type { } from './SideContentTypes'; import { SideContentType } from './SideContentTypes'; -const requireProvider = (x: string) => { +export const requireProvider = (x: string) => { const exports = { react: React, 'react/jsx-runtime': JSXRuntime, @@ -42,7 +42,9 @@ const requireProvider = (x: string) => { return exports[x as keyof typeof exports] as any; }; -type RawTab = (provider: ReturnType) => { default: ModuleSideContent }; +export type RawTab = (provider: ReturnType) => { + default: ModuleSideContent; +}; /** * Returns an array of SideContentTabs to be spawned diff --git a/src/features/conductor/TestPlugin.ts b/src/features/conductor/TestPlugin.ts index 699f00c4f6..c43255e4bd 100644 --- a/src/features/conductor/TestPlugin.ts +++ b/src/features/conductor/TestPlugin.ts @@ -1,7 +1,2 @@ import { TestPlugin as TestPluginAbstractClass } from '@sourceacademy/web-test'; -export class TestPlugin extends TestPluginAbstractClass { - constructor(...args: ConstructorParameters) { - console.log('TestPlugin constructor called with args:', args); - super(...args); - } -} +export class TestPlugin extends TestPluginAbstractClass {}