diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index ec5b1eb56d..620ba59a4a 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -8,6 +8,7 @@ import { Runner } from './runner'; import { AppState } from './state'; import { TaskRunner } from './task-runner'; import { activateTheme, getCurrentTheme, getTheme } from './themes'; +import { isMainEntryPoint } from './utils/editor-utils'; import { getPackageJson } from './utils/get-package'; import { getElectronVersions } from './versions'; import { @@ -33,6 +34,7 @@ export class App { public runner = new Runner(this.state); public readonly taskRunner: TaskRunner; public readonly electronTypes: ElectronTypes; + private notifiedMultipleMainFiles = false; constructor() { this.getEditorValues = this.getEditorValues.bind(this); @@ -56,6 +58,17 @@ export class App { }); } + private notifyIfMultipleMainFiles(editorValues: EditorValues) { + const mainFileCount = + Object.keys(editorValues).filter(isMainEntryPoint).length; + if (mainFileCount > 1 && !this.notifiedMultipleMainFiles) { + this.state.showInfoDialog( + 'Multiple main entry point files detected. You can right-click on any main file and select "Set as Main Entry Point" to choose which one to use.', + ); + this.notifiedMultipleMainFiles = true; + } + } + public async replaceFiddle( editorValues: EditorValues, { localFiddle, gistId, templateName }: Partial, @@ -69,6 +82,8 @@ export class App { this.state.editorMosaic.set(editorValues); + this.notifyIfMultipleMainFiles(editorValues); + this.state.gistId = gistId || ''; this.state.localPath = localFiddle?.filePath; this.state.templateName = templateName; diff --git a/src/renderer/components/sidebar-file-tree.tsx b/src/renderer/components/sidebar-file-tree.tsx index 34e57b20a3..eaf9a8f12d 100644 --- a/src/renderer/components/sidebar-file-tree.tsx +++ b/src/renderer/components/sidebar-file-tree.tsx @@ -49,7 +49,8 @@ export const SidebarFileTree = observer( .map(([editorId, presence], index) => { const visibilityIcon = presence !== EditorPresence.Hidden ? 'eye-open' : 'eye-off'; - + const isInactive = + isMainEntryPoint(editorId) && this.getMainEntryPoint() !== editorId; return { isSelected: focusedFile === editorId, id: index, @@ -58,6 +59,7 @@ export const SidebarFileTree = observer( label: ( this.setFocusedFile(editorId)} content={ @@ -67,6 +69,14 @@ export const SidebarFileTree = observer( intent="primary" onClick={() => this.renameEditor(editorId)} /> + {isMainEntryPoint(editorId) && ( + this.setMainEntryPoint(editorId)} + /> + )} { + const { appState } = this.props; + try { + appState.editorMosaic.setMainEntryPoint(editorId); + } catch (err) { + appState.showErrorDialog(err.message); + } + }; + + public getMainEntryPoint = () => { + const { editorMosaic } = this.props.appState; + return editorMosaic.mainEntryPointFile(); + }; + public removeEditor = (editorId: EditorId) => { const { editorMosaic } = this.props.appState; editorMosaic.remove(editorId); diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index b6ec14e1ac..f27b5b05f6 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -34,6 +34,7 @@ interface EditorBackup { } export class EditorMosaic { + public mainEntryPoint: EditorId | null = null; public isEdited = false; public focusedFile: EditorId | null = null; @@ -71,6 +72,7 @@ export class EditorMosaic { mosaic: observable, backups: observable, editors: observable, + mainEntryPoint: observable, setFocusedFile: action, resetLayout: action, set: action, @@ -79,6 +81,7 @@ export class EditorMosaic { setVisible: action, toggle: action, hide: action, + setMainEntryPoint: action, remove: action, addEditor: action, setEditorFromBackup: action, @@ -352,7 +355,29 @@ export class EditorMosaic { } public mainEntryPointFile(): EditorId | undefined { - return Array.from(this.files.keys()).find((id) => isMainEntryPoint(id)); + if (!this.mainEntryPoint || !this.files.get(this.mainEntryPoint)) { + const entryPoint = Array.from(this.files.keys()).find((id) => + isMainEntryPoint(id), + ); + if (entryPoint) this.mainEntryPoint = entryPoint; + return entryPoint; + } + return this.mainEntryPoint; + } + + public setMainEntryPoint(id: EditorId): void { + if (!this.files.has(id)) { + throw new Error( + `Cannot set main entry point to "${id}": File does not exist`, + ); + } + if (!isMainEntryPoint(id)) { + throw new Error( + `Cannot set main entry point to "${id}": Not a valid main entry point file`, + ); + } + this.mainEntryPoint = id; + this.isEdited = true; } //=== Listen for user edits diff --git a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap b/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap index 9082d866c7..cdf9208c82 100644 --- a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap +++ b/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap @@ -82,6 +82,16 @@ exports[`SidebarFileTree component can bring up the Add File input 1`] = ` shouldDismissPopover={true} text="Rename" /> + + + { expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); }); + it('can set a new main entry point file', () => { + const wrapper = shallow(); + const instance: any = wrapper.instance(); + + // Add MAIN_CJS to the mosaic + // NOTE: Using direct map manipulation for test setup only. + // In production code, files would be added through proper channels. + editorMosaic.files.set(MAIN_CJS, EditorPresence.Visible); + + instance.setMainEntryPoint(MAIN_JS); + + expect(instance.getMainEntryPoint()).toBe(MAIN_JS); + expect(editorMosaic.mainEntryPointFile()).toBe(MAIN_JS); + + instance.setMainEntryPoint(MAIN_CJS); + + expect(instance.getMainEntryPoint()).toBe(MAIN_CJS); + expect(editorMosaic.mainEntryPointFile()).toBe(MAIN_CJS); + }); + + it('fails when trying to set an invalid file as main entry point', () => { + const wrapper = shallow(); + const instance: any = wrapper.instance(); + + const REGULAR_FILE = 'index.html'; + + store.showErrorDialog = jest.fn().mockResolvedValueOnce(true); + + instance.setMainEntryPoint(REGULAR_FILE); + + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot set main entry point to "${REGULAR_FILE}": Not a valid main entry point file`, + ); + }); + + it('fails when trying to set a non-existent file as main entry point', () => { + const wrapper = shallow(); + const instance: any = wrapper.instance(); + + const NON_EXISTENT_FILE = 'non-existent-file.js'; + + store.showErrorDialog = jest.fn().mockResolvedValueOnce(true); + + instance.setMainEntryPoint(NON_EXISTENT_FILE); + + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot set main entry point to "${NON_EXISTENT_FILE}": File does not exist`, + ); + }); + + it('selects an available alternate main file after renaming active main file', async () => { + const wrapper = shallow(); + const instance: any = wrapper.instance(); + + // Add MAIN_CJS, MAIN_MJS to the mosaic + // NOTE: Using set() for test setup only. + // In production code, files would be added through proper channels. + editorMosaic.set({ + ...createEditorValues(), + [MAIN_CJS]: '// main.cjs content', + [MAIN_MJS]: '// main.mjs content', + }); + + instance.setMainEntryPoint(MAIN_JS); + + expect(instance.getMainEntryPoint()).toBe(MAIN_JS); + + store.showInputDialog = jest + .fn() + .mockResolvedValueOnce('not-a-main-file.js'); + + await instance.renameEditor(MAIN_JS); + + const newMainFile = instance.getMainEntryPoint(); + expect(newMainFile).not.toBe(MAIN_JS); + expect([MAIN_CJS, MAIN_MJS]).toContain(newMainFile); + }); + + it('sets main entry point to undefined when no main files exist after renaming', async () => { + const wrapper = shallow(); + const instance: any = wrapper.instance(); + + instance.setMainEntryPoint(MAIN_JS); + + expect(instance.getMainEntryPoint()).toBe(MAIN_JS); + + store.showInputDialog = jest + .fn() + .mockResolvedValueOnce('not-a-main-file.js'); + + await instance.renameEditor(MAIN_JS); + + expect(instance.getMainEntryPoint()).toBeUndefined(); + }); + it('can reset the editor layout', () => { const wrapper = shallow(); const instance: any = wrapper.instance();