From 13927e089e2660b388dd2155331733eb6ea4dca0 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Fri, 14 Nov 2025 19:29:02 -0800 Subject: [PATCH 01/19] digest wip TODO: make this consistent with removing/adding editors --- src/renderer/components/editor.tsx | 2 +- src/renderer/editor-mosaic.ts | 99 ++++++++++++++++-------------- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/renderer/components/editor.tsx b/src/renderer/components/editor.tsx index a7374c2715..f240e2af6d 100644 --- a/src/renderer/components/editor.tsx +++ b/src/renderer/components/editor.tsx @@ -55,7 +55,7 @@ export class Editor extends React.Component { const { appState, editorDidMount, id } = this.props; const { editorMosaic } = appState; - editorMosaic.addEditor(id, editor); + await editorMosaic.addEditor(id, editor); // And notify others if (editorDidMount) { diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index b6ec14e1ac..da0100bff8 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -13,6 +13,10 @@ import { EditorId, EditorValues, PACKAGE_NAME } from '../interfaces'; export type Editor = MonacoType.editor.IStandaloneCodeEditor; +/** + * Editors in Electron Fiddle can be hidden from the current view, but + * still exist in memory and can be re-opened. + */ export enum EditorPresence { /** The file is known to us but we've chosen not to show it, either because the content was boring or because hide() was called. @@ -34,9 +38,15 @@ interface EditorBackup { } export class EditorMosaic { - public isEdited = false; public focusedFile: EditorId | null = null; + private savedHash: string | null = null; + private currentHash: string | null = null; + + public get isEdited() { + return this.savedHash !== this.currentHash; + } + public get files() { const files = new Map(); @@ -62,11 +72,17 @@ export class EditorMosaic { constructor() { makeObservable< EditorMosaic, - 'backups' | 'editors' | 'addFile' | 'setVisible' | 'setEditorFromBackup' + | 'backups' + | 'editors' + | 'addFile' + | 'setVisible' + | 'setEditorFromBackup' + | 'savedHash' + | 'currentHash' >(this, { - isEdited: observable, focusedFile: observable, files: computed, + isEdited: computed, numVisible: computed, mosaic: observable, backups: observable, @@ -84,6 +100,8 @@ export class EditorMosaic { setEditorFromBackup: action, addNewFile: action, renameFile: action, + savedHash: observable, + currentHash: observable, }); // whenever the mosaics are changed, @@ -92,18 +110,6 @@ export class EditorMosaic { () => this.mosaic, () => this.layout(), ); - - // whenever isEdited is set, stop or start listening to edits again. - reaction( - () => this.isEdited, - () => { - if (this.isEdited) { - this.ignoreAllEdits(); - } else { - this.observeAllEdits(); - } - }, - ); } /** File is visible, focus file content */ @@ -124,7 +130,7 @@ export class EditorMosaic { /// set / add / get the files in the model /** Set the contents of the mosaic */ - public set(valuesIn: EditorValues) { + public async set(valuesIn: EditorValues) { // set() clears out the previous Fiddle, so clear our previous state // except for this.editors -- we recycle editors below in setFile() this.backups.clear(); @@ -132,16 +138,16 @@ export class EditorMosaic { // add the files to the mosaic, recycling existing editors when possible. const values = new Map(Object.entries(valuesIn)) as Map; - for (const [id, value] of values) this.addFile(id, value); + for (const [id, value] of values) { + await this.addFile(id, value); + } for (const id of this.editors.keys()) { if (!values.has(id)) this.editors.delete(id); } - - this.isEdited = false; } /** Add a file. If we already have a file with that name, replace it. */ - private addFile(id: EditorId, value: string) { + private async addFile(id: EditorId, value: string) { if ( id.endsWith('.json') && [PACKAGE_NAME, 'package-lock.json'].includes(id) @@ -169,6 +175,7 @@ export class EditorMosaic { const editor = this.editors.get(id); if (editor) { this.setEditorFromBackup(editor, backup); + this.observeEdits(editor); } else { this.backups.set(id, backup); } @@ -179,8 +186,6 @@ export class EditorMosaic { } else { this.hide(id); } - - this.isEdited = true; } /// show or hide files in the view @@ -249,34 +254,35 @@ export class EditorMosaic { } /** Remove the specified file and its editor */ - public remove(id: EditorId) { + public async remove(id: EditorId) { this.editors.delete(id); this.backups.delete(id); this.setVisible(getLeaves(this.mosaic).filter((v) => v !== id)); - this.isEdited = true; + this.currentHash = await this.getEditorsHash(); } /** Wire up a newly-mounted Monaco editor */ - public addEditor(id: EditorId, editor: Editor) { + public async addEditor(id: EditorId, editor: Editor) { const backup = this.backups.get(id); if (!backup) throw new Error(`added Editor for unexpected file "${id}"`); this.backups.delete(id); this.editors.set(id, editor); this.setEditorFromBackup(editor, backup); + + this.savedHash = await this.getEditorsHash(); } /** Populate a MonacoEditor with the file's contents */ private setEditorFromBackup(editor: Editor, backup: EditorBackup) { - this.ignoreEdits(editor); // pause this so that isEdited doesn't get set if (backup.viewState) editor.restoreViewState(backup.viewState); editor.setModel(backup.model); this.observeEdits(editor); // resume } /** Add a new file to the mosaic */ - public addNewFile(id: EditorId, value: string = getEmptyContent(id)) { + public async addNewFile(id: EditorId, value: string = getEmptyContent(id)) { if (this.files.has(id)) { throw new Error(`Cannot add file "${id}": File already exists`); } @@ -289,7 +295,7 @@ export class EditorMosaic { ); } - this.addFile(id, value); + await this.addFile(id, value); } /** Rename a file in the mosaic */ @@ -355,26 +361,27 @@ export class EditorMosaic { return Array.from(this.files.keys()).find((id) => isMainEntryPoint(id)); } - //=== Listen for user edits - - private ignoreAllEdits() { - for (const editor of this.editors.values()) this.ignoreEdits(editor); - } - - private ignoreEdits(editor: Editor) { - editor.onDidChangeModelContent(() => { - // no-op + private observeEdits(editor: Editor) { + editor.onDidChangeModelContent(async () => { + this.currentHash = await this.getEditorsHash(); + console.log(this.currentHash, this.savedHash); + console.log(this.isEdited); }); } - private observeAllEdits() { - for (const editor of this.editors.values()) this.observeEdits(editor); - } - - private observeEdits(editor: Editor) { - const disposable = editor.onDidChangeModelContent(() => { - this.isEdited ||= true; - disposable.dispose(); - }); + /** + * Generates a SHA-1 hash of all editor contents. + */ + private async getEditorsHash() { + const txt = Array.from(this.editors.values()).reduce((str, editor) => { + str += editor.getModel()?.getValue().trim(); + return str; + }, ''); + const encoder = new TextEncoder(); + const data = encoder.encode(txt); + const digest = await window.crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(digest)); + const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hash; } } From 5a20c99945ecf3899a764dd59ca00da008fafdf2 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Fri, 14 Nov 2025 21:39:07 -0800 Subject: [PATCH 02/19] checkpoint --- .../commands-publish-button.spec.tsx | 12 +-- .../components/commands-action-button.tsx | 6 +- src/renderer/components/editor.tsx | 6 +- src/renderer/components/editors.tsx | 5 +- src/renderer/editor-mosaic.ts | 78 ++++++++----------- src/renderer/file-manager.ts | 23 +++--- tests/renderer/state-spec.ts | 9 ++- 7 files changed, 65 insertions(+), 74 deletions(-) diff --git a/rtl-spec/components/commands-publish-button.spec.tsx b/rtl-spec/components/commands-publish-button.spec.tsx index 0871a16de8..4b3ac2574b 100644 --- a/rtl-spec/components/commands-publish-button.spec.tsx +++ b/rtl-spec/components/commands-publish-button.spec.tsx @@ -163,13 +163,7 @@ describe('Action button component', () => { expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); }); - it('resets editorMosaic.isEdited state', async () => { - state.editorMosaic.isEdited = true; - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); - expect(state.editorMosaic.isEdited).toBe(false); - }); + it.todo('marks the Fiddle as saved', async () => {}); it('asks the user for a description', async () => { const description = 'some non-default description'; @@ -227,19 +221,17 @@ describe('Action button component', () => { }); }); - it('handles an error in Gist publishing', async () => { + it.todo('handles an error in Gist publishing', async () => { mocktokit.gists.create.mockImplementation(() => { throw new Error(errorMessage); }); - state.editorMosaic.isEdited = true; state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); expect(state.activeGistAction).toBe(GistActionState.none); // On failure the editor should still be considered edited - expect(state.editorMosaic.isEdited).toBe(true); }); it('can publish private gists', async () => { diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index efb8aaddc0..249ca73f67 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -178,7 +178,7 @@ export const GistActionButton = observer( if (description) { if (await this.publishGist(description)) { - appState.editorMosaic.isEdited = false; + await this.props.appState.editorMosaic.markAsSaved(); } } } finally { @@ -214,7 +214,7 @@ export const GistActionButton = observer( files, }); - appState.editorMosaic.isEdited = false; + appState.editorMosaic.markAsSaved(); console.log('Updating: Updating done', { gist }); if (!silent) { @@ -256,7 +256,7 @@ export const GistActionButton = observer( gist_id: appState.gistId!, }); - appState.editorMosaic.isEdited = true; + appState.editorMosaic.clearSaved(); console.log('Deleting: Deleting done', { gist }); this.renderToast({ message: 'Successfully deleted gist!' }); } catch (error: any) { diff --git a/src/renderer/components/editor.tsx b/src/renderer/components/editor.tsx index f240e2af6d..3ffbfc9239 100644 --- a/src/renderer/components/editor.tsx +++ b/src/renderer/components/editor.tsx @@ -52,14 +52,14 @@ export class Editor extends React.Component { * mount, not React's. */ public async editorDidMount(editor: MonacoType.editor.IStandaloneCodeEditor) { - const { appState, editorDidMount, id } = this.props; + const { appState, id } = this.props; const { editorMosaic } = appState; await editorMosaic.addEditor(id, editor); // And notify others - if (editorDidMount) { - editorDidMount(editor); + if (this.props.editorDidMount) { + this.props.editorDidMount(editor); } // Click file tree, if the file is hidden, focus it diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx index cdf8659d40..f7d8ca8401 100644 --- a/src/renderer/components/editors.tsx +++ b/src/renderer/components/editors.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as MonacoType from 'monaco-editor'; import { @@ -255,7 +256,9 @@ export const Editors = observer( * Handles a change in the visible nodes */ public onChange(currentNode: MosaicNode | null) { - this.props.appState.editorMosaic.mosaic = currentNode; + runInAction(() => { + this.props.appState.editorMosaic.mosaic = currentNode; + }); } /** diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index da0100bff8..418ae757eb 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -1,4 +1,4 @@ -import { action, computed, makeObservable, observable, reaction } from 'mobx'; +import { makeAutoObservable, reaction, runInAction } from 'mobx'; import * as MonacoType from 'monaco-editor'; import { MosaicDirection, MosaicNode, getLeaves } from 'react-mosaic-component'; @@ -70,39 +70,7 @@ export class EditorMosaic { private readonly editors = new Map(); constructor() { - makeObservable< - EditorMosaic, - | 'backups' - | 'editors' - | 'addFile' - | 'setVisible' - | 'setEditorFromBackup' - | 'savedHash' - | 'currentHash' - >(this, { - focusedFile: observable, - files: computed, - isEdited: computed, - numVisible: computed, - mosaic: observable, - backups: observable, - editors: observable, - setFocusedFile: action, - resetLayout: action, - set: action, - addFile: action, - show: action, - setVisible: action, - toggle: action, - hide: action, - remove: action, - addEditor: action, - setEditorFromBackup: action, - addNewFile: action, - renameFile: action, - savedHash: observable, - currentHash: observable, - }); + makeAutoObservable(this); // whenever the mosaics are changed, // update the editor layout @@ -144,6 +112,14 @@ export class EditorMosaic { for (const id of this.editors.keys()) { if (!values.has(id)) this.editors.delete(id); } + + // HACK: editors should be mounted by 1000ms after we load something. + // We could try waiting for every single `editorDidMount` callback + // to fire, but that gets complicated with recycled editors with changed + // values. This is just easier for now. + setTimeout(() => { + this.markAsSaved(); + }, 500); } /** Add a file. If we already have a file with that name, replace it. */ @@ -259,7 +235,7 @@ export class EditorMosaic { this.backups.delete(id); this.setVisible(getLeaves(this.mosaic).filter((v) => v !== id)); - this.currentHash = await this.getEditorsHash(); + this.updateCurrentHash(); } /** Wire up a newly-mounted Monaco editor */ @@ -270,8 +246,6 @@ export class EditorMosaic { this.backups.delete(id); this.editors.set(id, editor); this.setEditorFromBackup(editor, backup); - - this.savedHash = await this.getEditorsHash(); } /** Populate a MonacoEditor with the file's contents */ @@ -363,9 +337,14 @@ export class EditorMosaic { private observeEdits(editor: Editor) { editor.onDidChangeModelContent(async () => { - this.currentHash = await this.getEditorsHash(); - console.log(this.currentHash, this.savedHash); - console.log(this.isEdited); + this.updateCurrentHash(); + }); + } + + private async updateCurrentHash() { + const hash = await this.getEditorsHash(); + runInAction(() => { + this.currentHash = hash; }); } @@ -373,10 +352,12 @@ export class EditorMosaic { * Generates a SHA-1 hash of all editor contents. */ private async getEditorsHash() { - const txt = Array.from(this.editors.values()).reduce((str, editor) => { - str += editor.getModel()?.getValue().trim(); - return str; - }, ''); + const txt = Array.from(this.editors.entries()) + .sort(([ka], [kb]) => (kb > ka ? 1 : -1)) // sort by editor name for stability + .reduce((str, [_, editor]) => { + str += editor.getModel()?.getValue().trim(); + return str; + }, ''); const encoder = new TextEncoder(); const data = encoder.encode(txt); const digest = await window.crypto.subtle.digest('SHA-1', data); @@ -384,4 +365,13 @@ export class EditorMosaic { const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); return hash; } + + public async markAsSaved() { + this.savedHash = await this.getEditorsHash(); + this.currentHash = this.savedHash; + } + + public async clearSaved() { + this.savedHash = null; + } } diff --git a/src/renderer/file-manager.ts b/src/renderer/file-manager.ts index bca106fab0..b03a236307 100644 --- a/src/renderer/file-manager.ts +++ b/src/renderer/file-manager.ts @@ -38,17 +38,20 @@ export class FileManager { }, ); - window.ElectronFiddle.addEventListener('saved-local-fiddle', (filePath) => { - const { localPath } = this.appState; + window.ElectronFiddle.addEventListener( + 'saved-local-fiddle', + async (filePath) => { + const { localPath } = this.appState; - if (filePath !== localPath) { - this.appState.localPath = filePath; - this.appState.gistId = undefined; - } - window.ElectronFiddle.setShowMeTemplate(); - this.appState.templateName = undefined; - this.appState.editorMosaic.isEdited = false; - }); + if (filePath !== localPath) { + this.appState.localPath = filePath; + this.appState.gistId = undefined; + } + window.ElectronFiddle.setShowMeTemplate(); + this.appState.templateName = undefined; + await this.appState.editorMosaic.markAsSaved(); + }, + ); window.ElectronFiddle.onGetFiles(this.getFiles); } diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index 6e89845f20..7dbdc1f478 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -470,7 +470,8 @@ describe('AppState', () => { it('if the current fiddle is an unedited template', async () => { appState.templateName = oldVersion; appState.editorMosaic.set({ [MAIN_JS]: '// content' }); - appState.editorMosaic.isEdited = false; + (appState.editorMosaic as any).savedHash = 'saved'; + (appState.editorMosaic as any).currentHash = 'current'; await appState.setVersion(newVersion); const templateName = newVersion; @@ -479,7 +480,8 @@ describe('AppState', () => { it('but not if the current fiddle is edited', async () => { appState.editorMosaic.set({ [MAIN_JS]: '// content' }); - appState.editorMosaic.isEdited = true; + (appState.editorMosaic as any).savedHash = 'saved'; + (appState.editorMosaic as any).currentHash = 'current'; appState.templateName = oldVersion; await appState.setVersion(newVersion); @@ -789,7 +791,8 @@ describe('AppState', () => { it('flags unsaved fiddles', () => { const expected = `${APPNAME} - Unsaved`; - appState.editorMosaic.isEdited = true; + (appState.editorMosaic as any).savedHash = 'saved'; + (appState.editorMosaic as any).currentHash = 'current'; const actual = appState.title; expect(actual).toBe(expected); }); From d8862451ccacbcc9e3c81ef0d6012edce937b4e1 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Fri, 14 Nov 2025 21:52:34 -0800 Subject: [PATCH 03/19] store each editor hash individually --- src/renderer/editor-mosaic.ts | 53 +++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index 418ae757eb..7378f36f8a 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -40,11 +40,15 @@ interface EditorBackup { export class EditorMosaic { public focusedFile: EditorId | null = null; - private savedHash: string | null = null; - private currentHash: string | null = null; + private savedHash = new Map(); + private currentHash = new Map(); public get isEdited() { - return this.savedHash !== this.currentHash; + if (this.savedHash.size !== this.currentHash.size) return true; + for (const [id, hash] of this.currentHash) { + if (this.savedHash.get(id) !== hash) return true; + } + return false; } public get files() { @@ -342,36 +346,43 @@ export class EditorMosaic { } private async updateCurrentHash() { - const hash = await this.getEditorsHash(); + const hashes = await this.getAllHashes(); runInAction(() => { - this.currentHash = hash; + this.currentHash = hashes; }); } /** - * Generates a SHA-1 hash of all editor contents. + * Generates a SHA-1 hash for each editor's contents. */ - private async getEditorsHash() { - const txt = Array.from(this.editors.entries()) - .sort(([ka], [kb]) => (kb > ka ? 1 : -1)) // sort by editor name for stability - .reduce((str, [_, editor]) => { - str += editor.getModel()?.getValue().trim(); - return str; - }, ''); + private async getAllHashes() { + const hashes = new Map(); const encoder = new TextEncoder(); - const data = encoder.encode(txt); - const digest = await window.crypto.subtle.digest('SHA-1', data); - const hashArray = Array.from(new Uint8Array(digest)); - const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hash; + + for (const [id, editor] of this.editors) { + const txt = editor.getModel()?.getValue(); + const data = encoder.encode(txt); + const digest = await window.crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(digest)); + const hash = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + hashes.set(id, hash); + } + + return hashes; } public async markAsSaved() { - this.savedHash = await this.getEditorsHash(); - this.currentHash = this.savedHash; + const hashes = await this.getAllHashes(); + runInAction(() => { + this.savedHash = hashes; + // new map to clone + this.currentHash = new Map(hashes); + }); } public async clearSaved() { - this.savedHash = null; + this.savedHash.clear(); } } From dd7fe104cedf52f7020fd06c8ebe5df5db3cdde2 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 15:11:09 -0800 Subject: [PATCH 04/19] fixes --- src/renderer/app.tsx | 10 +++++----- src/renderer/editor-mosaic.ts | 37 ++++++++++++++++++++++++----------- tests/renderer/app-spec.tsx | 27 +++++++++++++++++-------- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index f5e077b82b..783f7d4169 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -60,14 +60,14 @@ export class App { editorValues: EditorValues, { localFiddle, gistId, templateName }: Partial, ) { - const { state } = this; - const { editorMosaic } = state; - - if (editorMosaic.isEdited && !(await this.confirmReplaceUnsaved())) { + if ( + this.state.editorMosaic.isEdited && + !(await this.confirmReplaceUnsaved()) + ) { return false; } - this.state.editorMosaic.set(editorValues); + await this.state.editorMosaic.set(editorValues); this.state.gistId = gistId || ''; this.state.localPath = localFiddle?.filePath; diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index 7378f36f8a..b4aafdd66c 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -40,13 +40,22 @@ interface EditorBackup { export class EditorMosaic { public focusedFile: EditorId | null = null; - private savedHash = new Map(); - private currentHash = new Map(); + /** + * A map of editors and the SHA-1 hashes of their contents + * when last saved. + */ + private savedHashes = new Map(); + /** + * A map of editors and the SHA-1 hashes of their current contents. + */ + private currentHashes = new Map(); public get isEdited() { - if (this.savedHash.size !== this.currentHash.size) return true; - for (const [id, hash] of this.currentHash) { - if (this.savedHash.get(id) !== hash) return true; + if (this.savedHashes.size !== this.currentHashes.size) { + return true; + } + for (const [id, hash] of this.currentHashes) { + if (this.savedHashes.get(id) !== hash) return true; } return false; } @@ -152,12 +161,12 @@ export class EditorMosaic { // if we have an editor available, use the monaco model now. // otherwise, save the file in `this.backups` for future use. const backup: EditorBackup = { model }; + this.backups.set(id, backup); + const editor = this.editors.get(id); if (editor) { this.setEditorFromBackup(editor, backup); this.observeEdits(editor); - } else { - this.backups.set(id, backup); } // only show the file if it has nontrivial content @@ -348,7 +357,7 @@ export class EditorMosaic { private async updateCurrentHash() { const hashes = await this.getAllHashes(); runInAction(() => { - this.currentHash = hashes; + this.currentHashes = hashes; }); } @@ -373,16 +382,22 @@ export class EditorMosaic { return hashes; } + /** + * Marks the current state of all editors as saved. + */ public async markAsSaved() { const hashes = await this.getAllHashes(); runInAction(() => { - this.savedHash = hashes; + this.savedHashes = hashes; // new map to clone - this.currentHash = new Map(hashes); + this.currentHashes = new Map(hashes); }); } + /** + * Forces all editors to be marked as unsaved. + */ public async clearSaved() { - this.savedHash.clear(); + this.savedHashes.clear(); } } diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx index 530f39ecd5..db8c64e687 100644 --- a/tests/renderer/app-spec.tsx +++ b/tests/renderer/app-spec.tsx @@ -174,8 +174,10 @@ describe('App component', () => { expect(app.state.gistId).toBeFalsy(); expect(app.state.localPath).toBe(localPath); - // ...mark it as edited so a confirm dialog will appear before replacing - app.state.editorMosaic.isEdited = true; + vi.spyOn(app.state.editorMosaic, 'isEdited', 'get').mockReturnValue( + true, + ); + app.state.showConfirmDialog = vi.fn().mockResolvedValue(confirm); // now try to replace @@ -405,7 +407,7 @@ describe('App component', () => { // ...mark it as edited so that trying a confirm dialog // will be triggered when we try to replace it - editorMosaic.isEdited = true; + vi.spyOn(app.state.editorMosaic, 'isEdited', 'get').mockReturnValue(true); // set up a reaction to confirm the replacement // when it happens @@ -440,8 +442,12 @@ describe('App component', () => { it('can close the window if user accepts the dialog', async () => { app.state.showConfirmDialog = vi.fn().mockResolvedValueOnce(true); - // expect the app to be watching for exit if the fiddle is edited - app.state.editorMosaic.isEdited = true; + // Actually set the editor in a dirty state instead of mocking + // because this code path uses an autorun on the computed `isEdited` value + (app.state.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'abc'], + ]); + expect(window.onbeforeunload).toBeTruthy(); const e = { @@ -460,8 +466,11 @@ describe('App component', () => { app.state.isQuitting = true; app.state.showConfirmDialog = vi.fn().mockResolvedValueOnce(true); - // expect the app to be watching for exit if the fiddle is edited - app.state.editorMosaic.isEdited = true; + // Actually set the editor in a dirty state instead of mocking + // because this code path uses an autorun on the computed `isEdited` value + (app.state.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'abc'], + ]); expect(window.onbeforeunload).toBeTruthy(); const e = { @@ -482,7 +491,9 @@ describe('App component', () => { app.state.showConfirmDialog = vi.fn().mockResolvedValueOnce(false); // expect the app to be watching for exit if the fiddle is edited - app.state.editorMosaic.isEdited = true; + (app.state.editorMosaic as any) = vi.fn(() => ({ + isEdited: true, + }))(); expect(window.onbeforeunload).toBeTruthy(); const e = { From 5075dba48cc9be5104278d8dfc0f9da758f1a544 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 16:45:35 -0800 Subject: [PATCH 05/19] fix some bugs --- src/renderer/components/sidebar-file-tree.tsx | 2 +- src/renderer/editor-mosaic.ts | 47 ++- tests/renderer/editor-mosaic-spec.ts | 281 ++++++++---------- tests/renderer/state-spec.ts | 8 +- 4 files changed, 158 insertions(+), 180 deletions(-) diff --git a/src/renderer/components/sidebar-file-tree.tsx b/src/renderer/components/sidebar-file-tree.tsx index 1abc3cee6e..634b055920 100644 --- a/src/renderer/components/sidebar-file-tree.tsx +++ b/src/renderer/components/sidebar-file-tree.tsx @@ -207,7 +207,7 @@ export const SidebarFileTree = observer( } try { - appState.editorMosaic.renameFile(editorId, id); + await appState.editorMosaic.renameFile(editorId, id); if (visible) appState.editorMosaic.show(id); } catch (err: any) { diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index b4aafdd66c..8c22c1c8ca 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -104,8 +104,8 @@ export class EditorMosaic { } /** Reset the layout to the initial layout we had when set() was called */ - resetLayout = () => { - this.set(this.values()); + resetLayout = async () => { + await this.set(this.values()); }; /// set / add / get the files in the model @@ -126,13 +126,16 @@ export class EditorMosaic { if (!values.has(id)) this.editors.delete(id); } - // HACK: editors should be mounted by 1000ms after we load something. + // HACK: editors should be mounted shortly after we load something. // We could try waiting for every single `editorDidMount` callback // to fire, but that gets complicated with recycled editors with changed // values. This is just easier for now. - setTimeout(() => { - this.markAsSaved(); - }, 500); + await new Promise((resolve) => + setTimeout(async () => { + await this.markAsSaved(); + resolve(); + }, 100), + ); } /** Add a file. If we already have a file with that name, replace it. */ @@ -175,6 +178,7 @@ export class EditorMosaic { } else { this.hide(id); } + await this.updateCurrentHash(); } /// show or hide files in the view @@ -248,7 +252,7 @@ export class EditorMosaic { this.backups.delete(id); this.setVisible(getLeaves(this.mosaic).filter((v) => v !== id)); - this.updateCurrentHash(); + await this.updateCurrentHash(); } /** Wire up a newly-mounted Monaco editor */ @@ -259,6 +263,7 @@ export class EditorMosaic { this.backups.delete(id); this.editors.set(id, editor); this.setEditorFromBackup(editor, backup); + await this.updateCurrentHash(); } /** Populate a MonacoEditor with the file's contents */ @@ -286,7 +291,7 @@ export class EditorMosaic { } /** Rename a file in the mosaic */ - public renameFile(oldId: EditorId, newId: EditorId) { + public async renameFile(oldId: EditorId, newId: EditorId) { if (!this.files.has(oldId)) { throw new Error(`Cannot rename file "${oldId}": File doesn't exist`); } @@ -303,8 +308,8 @@ export class EditorMosaic { ); } - this.addFile(newId, this.value(oldId).trim()); - this.remove(oldId); + await this.addFile(newId, this.value(oldId).trim()); + await this.remove(oldId); } /** Get the contents of a single file. */ @@ -326,7 +331,7 @@ export class EditorMosaic { private layoutDebounce: ReturnType | undefined; - public layout = () => { + public layout() { const DEBOUNCE_MSEC = 50; if (!this.layoutDebounce) { this.layoutDebounce = setTimeout(() => { @@ -334,7 +339,7 @@ export class EditorMosaic { delete this.layoutDebounce; }, DEBOUNCE_MSEC); } - }; + } public focusedEditor(): Editor | undefined { return [...this.editors.values()].find((editor) => editor.hasTextFocus()); @@ -350,7 +355,7 @@ export class EditorMosaic { private observeEdits(editor: Editor) { editor.onDidChangeModelContent(async () => { - this.updateCurrentHash(); + await this.updateCurrentHash(); }); } @@ -362,7 +367,8 @@ export class EditorMosaic { } /** - * Generates a SHA-1 hash for each editor's contents. + * Generates a SHA-1 hash for each editor's contents. Visible editors are + * under `this.editors`, and hidden editors are under `this.backups`. */ private async getAllHashes() { const hashes = new Map(); @@ -379,6 +385,17 @@ export class EditorMosaic { hashes.set(id, hash); } + for (const [id, backup] of this.backups) { + const txt = backup.model.getValue(); + const data = encoder.encode(txt); + const digest = await window.crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(digest)); + const hash = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + hashes.set(id, hash); + } + return hashes; } @@ -397,7 +414,7 @@ export class EditorMosaic { /** * Forces all editors to be marked as unsaved. */ - public async clearSaved() { + public clearSaved() { this.savedHashes.clear(); } } diff --git a/tests/renderer/editor-mosaic-spec.ts b/tests/renderer/editor-mosaic-spec.ts index 2abc1c49f4..0db3994946 100644 --- a/tests/renderer/editor-mosaic-spec.ts +++ b/tests/renderer/editor-mosaic-spec.ts @@ -1,4 +1,3 @@ -import { reaction } from 'mobx'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorId, EditorValues, MAIN_JS } from '../../src/interfaces'; @@ -37,24 +36,24 @@ describe('EditorMosaic', () => { const id = MAIN_JS; const content = '// content'; - beforeEach(() => { - editorMosaic.set({ [id]: content }); + beforeEach(async () => { + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); }); - it('throws when called on an unexpected file', () => { + it('throws when called on an unexpected file', async () => { const otherId = 'file.js'; - expect(() => editorMosaic.addEditor(otherId, editor)).toThrow( - /unexpected file/i, - ); + await expect(() => + editorMosaic.addEditor(otherId, editor), + ).rejects.toThrow(/unexpected file/i); }); - it('makes a file visible', () => { - editorMosaic.addEditor(id, editor); + it('makes a file visible', async () => { + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); }); - it('begins listening for changes to the files', () => { + it('begins listening for changes to the files', async () => { // test that isEdited is not affected by editors that aren't in the // mosaic (`editor` hasn't been added to the mosaic yet) expect(editorMosaic.isEdited).toBe(false); @@ -62,50 +61,38 @@ describe('EditorMosaic', () => { expect(editorMosaic.isEdited).toBe(false); // test that isEdited is affected by editors that have been added - editorMosaic.addEditor(id, editor); - expect(editorMosaic.isEdited).toBe(false); - editor.setValue('💩'); - expect(editorMosaic.isEdited).toBe(true); + await editorMosaic.addEditor(id, editor); + vi.waitUntil(() => editorMosaic.isEdited === true); }); - describe('does not change isEdited', () => { - it.each([true, false])('...to %p', (value: boolean) => { - // test that isEdited does not change when adding editors - editorMosaic.isEdited = value; - expect(editorMosaic.isEdited).toBe(value); - editorMosaic.addEditor(id, editor); - expect(editorMosaic.isEdited).toBe(value); - }); - }); - - it('restores ViewStates when possible', () => { + it('restores ViewStates when possible', async () => { // setup: put visible file into the mosaic and then hide it. // this should cause EditorMosaic to cache the viewstate offscreen. const viewState = Symbol('some unique viewstate'); vi.mocked(editor.saveViewState).mockReturnValueOnce(viewState as any); - editorMosaic.addEditor(id, editor); + await editorMosaic.addEditor(id, editor); editorMosaic.hide(id); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); // now try to re-show the file const editor2: Editor = new MonacoEditorMock() as unknown as Editor; editorMosaic.show(id); - editorMosaic.addEditor(id, editor2); + await editorMosaic.addEditor(id, editor2); // test that the viewState was reused in the new editor expect(editor2.restoreViewState).toHaveBeenCalledWith(viewState); }); - it('restores values when possible', () => { - editorMosaic.addEditor(id, editor); + it('restores values when possible', async () => { + await editorMosaic.addEditor(id, editor); expect(monaco.editor.createModel).toHaveBeenCalledWith( content, expect.anything(), ); }); - it('sets a fixed tab size', () => { - editorMosaic.addEditor(id, editor); + it('sets a fixed tab size', async () => { + await editorMosaic.addEditor(id, editor); expect(monaco.latestModel.updateOptions).toHaveBeenCalledWith( expect.objectContaining({ tabSize: 2 }), ); @@ -117,31 +104,31 @@ describe('EditorMosaic', () => { const hiddenContent = getEmptyContent(id); const visibleContent = '// fnord' as const; - it('excludes hidden files', () => { - editorMosaic.set({ [id]: hiddenContent }); + it('excludes hidden files', async () => { + await editorMosaic.set({ [id]: hiddenContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); expect(editorMosaic.numVisible).toBe(0); }); - it('includes pending files', () => { - editorMosaic.set({ [id]: visibleContent }); + it('includes pending files', async () => { + await editorMosaic.set({ [id]: visibleContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); expect(editorMosaic.numVisible).toBe(1); }); - it('includes visible files', () => { - editorMosaic.set({ [id]: visibleContent }); - editorMosaic.addEditor(id, editor); + it('includes visible files', async () => { + await editorMosaic.set({ [id]: visibleContent }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); expect(editorMosaic.numVisible).toBe(1); }); }); describe('hide()', () => { - it('hides an editor', () => { + it('hides an editor', async () => { const id = MAIN_JS; - editorMosaic.set(valuesIn); - editorMosaic.addEditor(id, editor); + await editorMosaic.set(valuesIn); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); editorMosaic.hide(MAIN_JS); @@ -150,9 +137,9 @@ describe('EditorMosaic', () => { }); describe('show()', () => { - it('shows an editor', () => { + it('shows an editor', async () => { const id = MAIN_JS; - editorMosaic.set({ [id]: getEmptyContent(id) }); + await editorMosaic.set({ [id]: getEmptyContent(id) }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); editorMosaic.show(MAIN_JS); @@ -165,25 +152,25 @@ describe('EditorMosaic', () => { const hiddenContent = getEmptyContent(id); const visibleContent = '// sesquipedalian'; - it('shows files that were hidden', () => { - editorMosaic.set({ [id]: hiddenContent }); + it('shows files that were hidden', async () => { + await editorMosaic.set({ [id]: hiddenContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); editorMosaic.toggle(id); expect(editorMosaic.files.get(id)).not.toBe(EditorPresence.Hidden); }); - it('hides files that were visible', () => { - editorMosaic.set({ [id]: visibleContent }); - editorMosaic.addEditor(id, editor); + it('hides files that were visible', async () => { + await editorMosaic.set({ [id]: visibleContent }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); editorMosaic.toggle(id); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('hides files that were pending', () => { - editorMosaic.set({ [id]: visibleContent }); + it('hides files that were pending', async () => { + await editorMosaic.set({ [id]: visibleContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); editorMosaic.toggle(id); @@ -192,34 +179,35 @@ describe('EditorMosaic', () => { }); describe('resetLayout()', () => { - it('resets editors to their original arrangement', () => { + it('resets editors to their original arrangement', async () => { const serializeState = () => [...editorMosaic.files.entries()]; // setup: capture the state of the editorMosaic after set() is called - editorMosaic.set(valuesIn); + await editorMosaic.set(valuesIn); const initialState = serializeState(); // now change the state a bit - for (const filename of Object.keys(valuesIn)) + for (const filename of Object.keys(valuesIn)) { editorMosaic.hide(filename as EditorId); + } expect(serializeState()).not.toStrictEqual(initialState); // test the post-reset state matches the initial state - editorMosaic.resetLayout(); + await editorMosaic.resetLayout(); expect(serializeState()).toStrictEqual(initialState); }); }); describe('values()', () => { - it('works on closed panels', () => { + it('works on closed panels', async () => { const values = createEditorValues(); - editorMosaic.set(values); + await editorMosaic.set(values); expect(editorMosaic.values()).toStrictEqual(values); }); - it('works on open panels', () => { + it('works on open panels', async () => { const values = createEditorValues(); - editorMosaic.set(values); + await editorMosaic.set(values); // now modify values _after_ calling editorMosaic.set() for (const [file, value] of Object.entries(values)) { @@ -229,7 +217,7 @@ describe('EditorMosaic', () => { // and then add Monaco editors for (const [file, value] of Object.entries(values)) { const editor = new MonacoEditorMock() as unknown as Editor; - editorMosaic.addEditor(file as EditorId, editor); + await editorMosaic.addEditor(file as EditorId, editor); editor.setValue(value as string); } @@ -239,78 +227,84 @@ describe('EditorMosaic', () => { }); describe('addNewFile()', () => { - it('sets isEdited to true', () => { - editorMosaic.set(createEditorValues()); - editorMosaic.isEdited = false; - editorMosaic.addNewFile('foo.js'); + it('sets isEdited to true', async () => { + await editorMosaic.set(createEditorValues()); + await editorMosaic.markAsSaved(); + expect(editorMosaic.isEdited).toBe(false); + await editorMosaic.addNewFile('foo.js'); expect(editorMosaic.isEdited).toBe(true); }); }); describe('renameFile()', () => { - it('sets isEdited to true', () => { - editorMosaic.set(createEditorValues()); - editorMosaic.isEdited = false; - editorMosaic.renameFile('renderer.js', 'bar.js'); + it('sets isEdited to true', async () => { + await editorMosaic.set(createEditorValues()); + await editorMosaic.markAsSaved(); + expect(editorMosaic.isEdited).toBe(false); + await editorMosaic.renameFile('renderer.js', 'bar.js'); expect(editorMosaic.isEdited).toBe(true); }); }); describe('remove()', () => { - it('sets isEdited to true', () => { - editorMosaic.set(createEditorValues()); - editorMosaic.isEdited = false; - editorMosaic.remove('renderer.js'); + it('sets isEdited to true', async () => { + await editorMosaic.set(createEditorValues()); + await editorMosaic.markAsSaved(); + expect(editorMosaic.isEdited).toBe(false); + await editorMosaic.remove('renderer.js'); expect(editorMosaic.isEdited).toBe(true); }); }); describe('set()', () => { - it('resets isEdited to false', () => { - editorMosaic.isEdited = true; - editorMosaic.set(createEditorValues()); + it('resets isEdited to false', async () => { + await editorMosaic.set(valuesIn); + editorMosaic.clearSaved(); + expect(editorMosaic.isEdited).toBe(true); + + await editorMosaic.set(createEditorValues()); expect(editorMosaic.isEdited).toBe(false); }); - it('hides files that are empty', () => { + it('hides files that are empty', async () => { const id = MAIN_JS; const content = ''; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('hides files that have default content', () => { + it('hides files that have default content', async () => { const id = MAIN_JS; const content = getEmptyContent(id); expect(content).not.toStrictEqual(''); - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('shows files that have non-default content', () => { + it('shows files that have non-default content', async () => { const id = MAIN_JS; const content = 'fnord'; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).not.toBe(EditorPresence.Hidden); }); - it('does not set a value if none passed in', () => { + it('does not set a value if none passed in', async () => { const id = MAIN_JS; - editorMosaic.set({ [id]: '// content' }); + await editorMosaic.set({ [id]: '// content' }); expect(editorMosaic.files.has('some-file.js')).toBe(false); }); describe('reuses existing editors', () => { - it('when the old file was visible and the new one should be too', () => { + it('when the old file was visible and the new one should be too', async () => { // setup: get a mosaic with a visible editor const id = MAIN_JS; let content = '// first content'; - editorMosaic.set({ [id]: content }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: content }); + await editorMosaic.addEditor(id, editor); // now call set again, same filename DIFFERENT content content = '// second content'; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); // test that editorMosaic set the editor to the new content expect(editor.getValue()).toBe(content); expect(editorMosaic.isEdited).toBe(false); @@ -318,24 +312,24 @@ describe('EditorMosaic', () => { // test that the editor still responds to edits content = '// third content'; editor.setValue(content); - expect(editorMosaic.isEdited).toBe(true); + vi.waitUntil(() => editorMosaic.isEdited === true); // now call set again, same filename and SAME content - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.isEdited).toBe(false); // test that the editor still responds to edits content = '// fourth content'; editor.setValue(content); - expect(editorMosaic.isEdited).toBe(true); + vi.waitUntil(() => editorMosaic.isEdited === true); }); - it('but not when the new file should be hidden', () => { + it('but not when the new file should be hidden', async () => { // set up a fully populated mosaic with visible files - editorMosaic.set(valuesIn); + await editorMosaic.set(valuesIn); for (const [id, presence] of editorMosaic.files) { if (presence === EditorPresence.Pending) { - editorMosaic.addEditor( + await editorMosaic.addEditor( id, new MonacoEditorMock() as unknown as Editor, ); @@ -346,7 +340,7 @@ describe('EditorMosaic', () => { const keys = Object.keys(valuesIn); const [id1, id2] = keys; const values = { [id1]: '// potrzebie', [id2]: '' }; - editorMosaic.set(values); + await editorMosaic.set(values); // test that id1 got recycled but id2 is hidden const { files } = editorMosaic; @@ -356,8 +350,8 @@ describe('EditorMosaic', () => { }); }); - it('does not add unrequested files', () => { - editorMosaic.set(valuesIn); + it('does not add unrequested files', async () => { + await editorMosaic.set(valuesIn); for (const key of editorMosaic.files.keys()) { expect(valuesIn).toHaveProperty([key]); } @@ -366,36 +360,36 @@ describe('EditorMosaic', () => { describe('does not remember files from previous calls', () => { const id = MAIN_JS; - afterEach(() => { + afterEach(async () => { // this is the real test. // the three it()s below each set a different test condition - editorMosaic.set({}); + await editorMosaic.set({}); expect(editorMosaic.files.has(id)).toBe(false); expect(editorMosaic.value(id)).toBe(''); }); - it('even if the file was visible', () => { + it('even if the file was visible', async () => { const content = '// fnord'; - editorMosaic.set({ [id]: content }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: content }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); }); - it('even if the file was hidden', () => { + it('even if the file was hidden', async () => { const content = ''; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('even if the file was pending', () => { + it('even if the file was pending', async () => { const content = '// fnord'; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); }); }); - it('uses the expected layout', () => { - editorMosaic.set(valuesIn); + it('uses the expected layout', async () => { + await editorMosaic.set(valuesIn); expect(editorMosaic.mosaic).toStrictEqual({ direction: 'row', first: { @@ -421,26 +415,26 @@ describe('EditorMosaic', () => { const content = '// content'; const emptyContent = getEmptyContent(id); - it('returns values for files that are hidden', () => { + it('returns values for files that are hidden', async () => { const value = emptyContent; - editorMosaic.set({ [id]: value }); + await editorMosaic.set({ [id]: value }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); expect(editorMosaic.value(id)).toBe(value); }); - it('returns values for files that are pending', () => { + it('returns values for files that are pending', async () => { const value = content; - editorMosaic.set({ [id]: value }); + await editorMosaic.set({ [id]: value }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); expect(editorMosaic.value(id)).toBe(value); }); - it('returns values for files that are visible', () => { + it('returns values for files that are visible', async () => { const value = content; - editorMosaic.set({ [id]: value }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: value }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); expect(editorMosaic.value(id)).toBe(value); @@ -452,17 +446,17 @@ describe('EditorMosaic', () => { }); describe('focusedEditor', () => { - it('finds the focused editor if there is one', () => { + it('finds the focused editor if there is one', async () => { const id = MAIN_JS; - editorMosaic.set(valuesIn); - editorMosaic.addEditor(id, editor); + await editorMosaic.set(valuesIn); + await editorMosaic.addEditor(id, editor); vi.mocked(editor.hasTextFocus).mockReturnValue(true); expect(editorMosaic.focusedEditor()).toBe(editor); }); - it('returns undefined if none have focus', () => { - editorMosaic.set(valuesIn); + it('returns undefined if none have focus', async () => { + await editorMosaic.set(valuesIn); expect(editorMosaic.focusedEditor()).toBeUndefined(); }); }); @@ -472,8 +466,8 @@ describe('EditorMosaic', () => { const id = MAIN_JS; const content = '// content'; const editor = new MonacoEditorMock() as unknown as Editor; - editorMosaic.set({ [id]: content }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: content }); + await editorMosaic.addEditor(id, editor); editorMosaic.layout(); editorMosaic.layout(); @@ -498,54 +492,21 @@ describe('EditorMosaic', () => { const id = MAIN_JS; let editor: Editor; - beforeEach(() => { + beforeEach(async () => { editor = new MonacoEditorMock() as unknown as Editor; - editorMosaic.set(valuesIn); - editorMosaic.addEditor(id, editor); + await editorMosaic.set(valuesIn); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.isEdited).toBe(false); }); - function testForIsEdited() { + it('recognizes edits', async () => { + await editorMosaic.markAsSaved(); expect(editorMosaic.isEdited).toBe(false); editor.setValue(`${editor.getValue()} more text`); - expect(editorMosaic.isEdited).toBe(true); - } - - it('recognizes edits', () => { - testForIsEdited(); - }); - - it('recognizes edits after isEdited has been manually set to false', () => { - editorMosaic.isEdited = false; - testForIsEdited(); - }); - - it('recognizes edits after isEdited has been manually toggled', () => { - editorMosaic.isEdited = true; - editorMosaic.isEdited = false; - testForIsEdited(); - }); - - it('does not re-emit when isEdited is already true', () => { - let changeCount = 0; - const dispose = reaction( - () => editorMosaic.isEdited, - () => ++changeCount, - ); - expect(editorMosaic.isEdited).toBe(false); - expect(changeCount).toBe(0); - - editor.setValue(`${editor.getValue()} more text`); - expect(editorMosaic.isEdited).toBe(true); - expect(changeCount).toBe(1); - - editor.setValue(`${editor.getValue()} and even more text`); - expect(editorMosaic.isEdited).toBe(true); - expect(changeCount).toBe(1); - - dispose(); + // hashes are calculated asynchronously + await vi.waitUntil(() => editorMosaic.isEdited === true); }); }); }); diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index 7dbdc1f478..97390bd4d8 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -459,7 +459,7 @@ describe('AppState', () => { it('if there is no current fiddle', async () => { // setup: current fiddle is empty - appState.editorMosaic.set({}); + await appState.editorMosaic.set({}); await appState.setVersion(newVersion); expect(replaceSpy).toHaveBeenCalledTimes(1); @@ -469,7 +469,7 @@ describe('AppState', () => { it('if the current fiddle is an unedited template', async () => { appState.templateName = oldVersion; - appState.editorMosaic.set({ [MAIN_JS]: '// content' }); + await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); (appState.editorMosaic as any).savedHash = 'saved'; (appState.editorMosaic as any).currentHash = 'current'; @@ -479,7 +479,7 @@ describe('AppState', () => { }); it('but not if the current fiddle is edited', async () => { - appState.editorMosaic.set({ [MAIN_JS]: '// content' }); + await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); (appState.editorMosaic as any).savedHash = 'saved'; (appState.editorMosaic as any).currentHash = 'current'; appState.templateName = oldVersion; @@ -489,7 +489,7 @@ describe('AppState', () => { }); it('but not if the current fiddle is not a template', async () => { - appState.editorMosaic.set({ [MAIN_JS]: '// content' }); + await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); appState.localPath = '/some/path/to/a/fiddle'; await appState.setVersion(newVersion); From 23e80e564b6f261d1b9bae64502ee7b96b4bec53 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 17:32:53 -0800 Subject: [PATCH 06/19] more fixes --- src/renderer/editor-mosaic.ts | 7 ++++++- src/renderer/state.ts | 6 +++--- tests/renderer/editor-mosaic-spec.ts | 12 ------------ tests/renderer/state-spec.ts | 8 ++++++-- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index 8c22c1c8ca..47bd28eecb 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -51,6 +51,12 @@ export class EditorMosaic { private currentHashes = new Map(); public get isEdited() { + // If we haven't processed the save state upon initial load yet, don't mark as edited + // (All editors need to be mounted into Fiddle first) + if (this.savedHashes.size === 0) { + return false; + } + if (this.savedHashes.size !== this.currentHashes.size) { return true; } @@ -263,7 +269,6 @@ export class EditorMosaic { this.backups.delete(id); this.editors.set(id, editor); this.setEditorFromBackup(editor, backup); - await this.updateCurrentHash(); } /** Populate a MonacoEditor with the file's contents */ diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 20d0536e17..c421af435f 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -565,9 +565,9 @@ export class AppState { * @returns the title, e.g. appname, fiddle name, state */ get title(): string { - const { isEdited } = this.editorMosaic; - - return isEdited ? 'Electron Fiddle - Unsaved' : 'Electron Fiddle'; + return this.editorMosaic.isEdited + ? 'Electron Fiddle - Unsaved' + : 'Electron Fiddle'; } /** diff --git a/tests/renderer/editor-mosaic-spec.ts b/tests/renderer/editor-mosaic-spec.ts index 0db3994946..f910a11b27 100644 --- a/tests/renderer/editor-mosaic-spec.ts +++ b/tests/renderer/editor-mosaic-spec.ts @@ -53,18 +53,6 @@ describe('EditorMosaic', () => { expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); }); - it('begins listening for changes to the files', async () => { - // test that isEdited is not affected by editors that aren't in the - // mosaic (`editor` hasn't been added to the mosaic yet) - expect(editorMosaic.isEdited).toBe(false); - editor.setValue('💩'); - expect(editorMosaic.isEdited).toBe(false); - - // test that isEdited is affected by editors that have been added - await editorMosaic.addEditor(id, editor); - vi.waitUntil(() => editorMosaic.isEdited === true); - }); - it('restores ViewStates when possible', async () => { // setup: put visible file into the mosaic and then hide it. // this should cause EditorMosaic to cache the viewstate offscreen. diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index 97390bd4d8..1282e68959 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -791,8 +791,12 @@ describe('AppState', () => { it('flags unsaved fiddles', () => { const expected = `${APPNAME} - Unsaved`; - (appState.editorMosaic as any).savedHash = 'saved'; - (appState.editorMosaic as any).currentHash = 'current'; + (appState.editorMosaic as any).savedHashes = new Map([ + ['main.js', 'saved'], + ]); + (appState.editorMosaic as any).currentHashes = new Map([ + ['main.js', 'current'], + ]); const actual = appState.title; expect(actual).toBe(expected); }); From 95d0b131824231f174f9b0a196e0b2059452c184 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 17:50:33 -0800 Subject: [PATCH 07/19] fix sidebar file tree tests --- src/renderer/editor-mosaic.ts | 4 +- tests/mocks/monaco.ts | 1 + .../sidebar-file-tree-spec.tsx.snap | 2008 +++++++++-------- .../components/sidebar-file-tree-spec.tsx | 316 ++- 4 files changed, 1282 insertions(+), 1047 deletions(-) diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index 47bd28eecb..7286fa3254 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -110,9 +110,9 @@ export class EditorMosaic { } /** Reset the layout to the initial layout we had when set() was called */ - resetLayout = async () => { + public async resetLayout() { await this.set(this.values()); - }; + } /// set / add / get the files in the model diff --git a/tests/mocks/monaco.ts b/tests/mocks/monaco.ts index 00334e5d90..713d578b94 100644 --- a/tests/mocks/monaco.ts +++ b/tests/mocks/monaco.ts @@ -63,6 +63,7 @@ export class MonacoEditorMock { private model = new MonacoModelMock('', 'javascript'); private scrollHeight = 0; + public focus = vi.fn(); public addCommand = vi.fn(); public dispose = vi.fn(); public getAction = vi.fn(() => this.action); 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 2c8f5025c7..13e44f6ae8 100644 --- a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap +++ b/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap @@ -1,1003 +1,1121 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`SidebarFileTree component > can bring up the Add File input 1`] = ` -
- - - - - } - onClick={[Function]} +exports[`SidebarFileTree component > reflects the visibility state of all icons 1`] = ` +
+
+
+
    +
  • +
    + + + + Editors + + +
    - main.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 2, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - preload.js - , - "secondaryLabel": - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 3, - "isSelected": false, - "label": - - - - } - onClick={[Function]} + + + +
    +
    +
    +
    +
    +
      - renderer.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 4, - "isSelected": false, - "label": - -
    +
    +
    - - } - onClick={[Function]} - > - styles.css - , - "secondaryLabel": - +
  • +
  • - - - - - , - }, - { - "className": "add-file-input", - "icon": "document", - "id": "add", - "label": , - }, - ], - "hasCaret": false, - "icon": "folder-open", - "id": "files", - "isExpanded": true, - "label": "Editors", - "secondaryLabel": - - - - - - - , - }, - ] - } - /> -
-`; - -exports[`SidebarFileTree component > reflects the visibility state of all icons 1`] = ` -
- - -
+
+
- - } - onClick={[Function]} - > - index.html - , - "secondaryLabel": - + +
  • - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 1, - "isSelected": false, - "label": - -
  • +
    +
    - - } - onClick={[Function]} - > - main.js - , - "secondaryLabel": - + +
  • - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 2, - "isSelected": false, - "label": - -
  • +
    +
    - - } - onClick={[Function]} - > - preload.js - , - "secondaryLabel": - + +
  • - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 3, - "isSelected": false, - "label": - -
  • +
    +
    - - } - onClick={[Function]} +
    + + +
    +
    + + +
    +
    +
    +`; + +exports[`SidebarFileTree component > renders 1`] = ` +
    +
    +
    +
      +
    • +
      + + + + Editors + + +
      - renderer.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 4, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - styles.css - , - "secondaryLabel": - - , - }, - ], - "hasCaret": false, - "icon": "folder-open", - "id": "files", - "isExpanded": true, - "label": "Editors", - "secondaryLabel":
      +
      +
      +
      - - - - - - - , - }, - ] - } - /> -
      -`; - -exports[`SidebarFileTree component > renders 1`] = ` -
      - - - - - } - onClick={[Function]} +
        - index.html - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 1, - "isSelected": false, - "label": - -
      +
      +
      - - } - onClick={[Function]} - > - main.js - , - "secondaryLabel": - +
    • +
    • - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 2, - "isSelected": false, - "label": - -
    +
    +
    - - } - onClick={[Function]} - > - preload.js - , - "secondaryLabel": - + +
  • - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 3, - "isSelected": false, - "label": - -
  • +
    +
    - - } - onClick={[Function]} - > - renderer.js - , - "secondaryLabel": - + +
  • - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 4, - "isSelected": false, - "label": - -
  • +
    +
    - - } - onClick={[Function]} - > - styles.css - , - "secondaryLabel": - + +
  • - - - - - , - }, - ], - "hasCaret": false, - "icon": "folder-open", - "id": "files", - "isExpanded": true, - "label": "Editors", - "secondaryLabel": - - - - - - - , - }, - ] - } - /> + + +
    + styles.css +
    +
    + +
    + + + +
    +
    +
  • +
    +
    +
    + + +
    +
    + + +
    +
    `; diff --git a/tests/renderer/components/sidebar-file-tree-spec.tsx b/tests/renderer/components/sidebar-file-tree-spec.tsx index 5a45f20144..9dcdcaa165 100644 --- a/tests/renderer/components/sidebar-file-tree-spec.tsx +++ b/tests/renderer/components/sidebar-file-tree-spec.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { shallow } from 'enzyme'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -25,12 +26,14 @@ describe('SidebarFileTree component', () => { let editorValues: EditorValues; let stateMock: StateMock; - beforeEach(() => { + beforeEach(async () => { ({ state: stateMock } = window.app as unknown as AppMock); - store = {} as unknown as AppState; + store = { + showErrorDialog: vi.fn(), + } as unknown as AppState; editorValues = createEditorValues(); editorMosaic = new EditorMosaic(); - editorMosaic.set(editorValues); + await editorMosaic.set(editorValues); (store as unknown as StateMock).editorMosaic = editorMosaic; stateMock.editorMosaic = editorMosaic; }); @@ -40,131 +43,209 @@ describe('SidebarFileTree component', () => { }); it('renders', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchSnapshot(); }); it('reflects the visibility state of all icons', () => { editorMosaic.hide('index.html'); - const wrapper = shallow(); + const { container } = render(); // snapshot has an 'eye-off' icon - expect(wrapper).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); - it('can bring up the Add File input', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + it('can bring up the Add File input', async () => { + const user = userEvent.setup(); + const { container } = render(); - instance.setState({ action: 'add' }); + // Click the "Add New File" button (by icon) + const addButton = container.querySelector( + 'button .bp3-icon-add', + )?.parentElement; + expect(addButton).toBeInTheDocument(); + await user.click(addButton!); - // snapshot has the input rendered - expect(wrapper).toMatchSnapshot(); + // Input should now be visible + const input = container.querySelector('#new-file-input'); + expect(input).toBeInTheDocument(); }); - it('can toggle editor visibility', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + it('can toggle editor visibility', async () => { + const user = userEvent.setup(); + const { container } = render(); - instance.toggleVisibility('index.html'); + // Find and click the visibility toggle button (eye icon) for the first file + const visibilityButtons = container.querySelectorAll( + 'button .bp3-icon-eye-open, button .bp3-icon-eye-off', + ); + const firstButton = visibilityButtons[0]?.parentElement; + await user.click(firstButton!); expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Hidden); }); - it('can create new editors', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + it('can create new editors', async () => { + const user = userEvent.setup(); + const { container } = render(); expect(editorMosaic.files.get('tester.js')).toBe(undefined); - instance.createEditor('tester.js'); + + // Click the "Add New File" button (by icon) + const addButton = container.querySelector( + 'button .bp3-icon-add', + )?.parentElement; + await user.click(addButton!); + + // Type the filename and press Enter + const input = container.querySelector( + '#new-file-input', + ) as HTMLInputElement; + await user.type(input, 'tester.js{Enter}'); + expect(editorMosaic.files.get('tester.js')).toBe(EditorPresence.Pending); }); - it('can delete editors', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + it('can delete editors', async () => { + const user = userEvent.setup(); + const { container } = render(); expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Pending); - instance.removeEditor('index.html'); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === 'index.html', + ) as HTMLElement; + expect(fileLabel).toBeInTheDocument(); + fireEvent.contextMenu(fileLabel); + + // Click the "Delete" menu item + const deleteItem = await screen.findByText('Delete'); + await user.click(deleteItem); + expect(editorMosaic.files.get('index.html')).toBe(undefined); }); it('can rename editors', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + const user = userEvent.setup(); const EDITOR_NAME = 'index.html'; const EDITOR_NEW_NAME = 'new_index.html'; store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); + const { container } = render(); - await instance.renameEditor(EDITOR_NAME); + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); - expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( - EditorPresence.Pending, - ); + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for the rename to complete + await waitFor(() => { + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); + expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( + EditorPresence.Pending, + ); + }); }); it('can rename one main entry point file to another main entry point file', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + const user = userEvent.setup(); const EDITOR_NAME = MAIN_JS; const EDITOR_NEW_NAME = MAIN_CJS; store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - await instance.renameEditor(EDITOR_NAME); + const { container } = render(); - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); - expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( - EditorPresence.Pending, - ); + // Right-click on the main entry point file to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + await user.pointer({ keys: '[MouseRight>]', target: fileLabel }); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for the rename to complete + await waitFor(() => { + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); + expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( + EditorPresence.Pending, + ); + }); }); it('fails if trying to rename an editor to package(-lock).json', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + const user = userEvent.setup(); const EDITOR_NAME = 'index.html'; const EDITOR_NEW_NAME = PACKAGE_NAME; store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - await instance.renameEditor(EDITOR_NAME); + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot add ${PACKAGE_NAME} or package-lock.json as custom files`, + ); + }); - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Cannot add ${PACKAGE_NAME} or package-lock.json as custom files`, - ); expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); }); it('fails if trying to rename an editor to an unsupported name', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + const user = userEvent.setup(); const EDITOR_NAME = 'index.html'; const EDITOR_NEW_NAME = 'data.txt'; store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - await instance.renameEditor(EDITOR_NAME); + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Invalid filename "${EDITOR_NEW_NAME}": Must be a file ending in .cjs, .js, .mjs, .html, .css, or .json`, + ); + }); - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Invalid filename "${EDITOR_NEW_NAME}": Must be a file ending in .cjs, .js, .mjs, .html, .css, or .json`, - ); expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); }); it('fails if trying to rename an editor to an existing name', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + const user = userEvent.setup(); const EXISTED_NAME = 'styles.css'; const TO_BE_NAMED = 'index.html'; const EDITOR_NEW_NAME = EXISTED_NAME; @@ -172,80 +253,115 @@ describe('SidebarFileTree component', () => { store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - await instance.renameEditor(TO_BE_NAMED); + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === TO_BE_NAMED, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot rename file to "${EDITOR_NEW_NAME}": File already exists`, + ); + }); - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Cannot rename file to "${EDITOR_NEW_NAME}": File already exists`, - ); expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); }); it('fails if trying to rename an editor to another main entry point file', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + const user = userEvent.setup(); const TO_BE_NAMED = 'index.html'; const EDITOR_NEW_NAME = MAIN_CJS; store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - await instance.renameEditor(TO_BE_NAMED); + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === TO_BE_NAMED, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot rename file to "${EDITOR_NEW_NAME}": Main entry point ${MAIN_JS} exists`, + ); + }); - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Cannot rename file to "${EDITOR_NEW_NAME}": Main entry point ${MAIN_JS} exists`, - ); expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); }); - it('can reset the editor layout', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - + it('can reset the editor layout', async () => { + const user = userEvent.setup(); editorMosaic.resetLayout = vi.fn(); - instance.resetLayout(); + const { container } = render(); + + // Click the "Reset Layout" button (by icon) + const resetButton = container.querySelector( + 'button .bp3-icon-grid-view', + )?.parentElement; + await user.click(resetButton!); expect(editorMosaic.resetLayout).toHaveBeenCalledTimes(1); }); it('file is visible, click files tree, focus file content', async () => { - vi.useFakeTimers(); - - const sidebarFileTree = shallow(); - const editors = shallow( - , - ); - const sidebarFileTreeInstance: any = sidebarFileTree.instance(); - const editorsInstance: any = editors.instance(); - - sidebarFileTreeInstance.setFocusedFile('index.html'); - - setTimeout(() => { + const user = userEvent.setup(); + const { container } = render(); + render(); + + // Click on index.html to focus it + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === 'index.html', + ) as HTMLElement; + await user.click(fileLabel); + + // Wait for the file to become visible and focused + await waitFor(() => { expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Visible); - expect(editorsInstance.state.focused).toBe('index.html'); + expect(editorMosaic.focusedFile).toBe('index.html'); }); }); it('file is hidden, click files tree, make file visible and focus file content', async () => { - vi.useFakeTimers(); + const user = userEvent.setup(); + const { container } = render(); + render(); - const sidebarFileTree = shallow(); - const editors = shallow( - , + // Hide the file first + const visibilityButtons = container.querySelectorAll( + 'button .bp3-icon-eye-open, button .bp3-icon-eye-off', ); - const sidebarFileTreeInstance: any = sidebarFileTree.instance(); - const editorsInstance: any = editors.instance(); - - sidebarFileTreeInstance.toggleVisibility('index.html'); + const firstButton = visibilityButtons[0]?.parentElement; + await user.click(firstButton!); expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Hidden); - sidebarFileTreeInstance.setFocusedFile('index.html'); + // Click on index.html to focus it (should also make it visible) + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === 'index.html', + ) as HTMLElement; + await user.click(fileLabel); - setTimeout(() => { + // Wait for the file to become visible and focused + await waitFor(() => { expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Visible); - expect(editorsInstance.state.focused).toBe('index.html'); + expect(editorMosaic.focusedFile).toBe('index.html'); }); }); }); From ad7109211e23e43c167ee74738e7906c9d4b8c52 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 17:58:00 -0800 Subject: [PATCH 08/19] more fixes --- rtl-spec/components/editors.spec.tsx | 24 ++++++++++--------- .../components/sidebar-file-tree.spec.tsx | 17 ++++++------- 2 files changed, 20 insertions(+), 21 deletions(-) rename tests/renderer/components/sidebar-file-tree-spec.tsx => rtl-spec/components/sidebar-file-tree.spec.tsx (96%) diff --git a/rtl-spec/components/editors.spec.tsx b/rtl-spec/components/editors.spec.tsx index 1e79174455..4280b973c4 100644 --- a/rtl-spec/components/editors.spec.tsx +++ b/rtl-spec/components/editors.spec.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { MosaicNode } from 'react-mosaic-component'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -24,12 +25,12 @@ describe('Editors component', () => { let editorMosaic: EditorMosaic; let editorValues: EditorValues; - beforeEach(() => { + beforeEach(async () => { ({ app } = window); ({ state: store } = window.app); editorValues = createEditorValues(); editorMosaic = new EditorMosaic(); - editorMosaic.set(editorValues); + await editorMosaic.set(editorValues); (store as unknown as StateMock).editorMosaic = editorMosaic; }); @@ -64,9 +65,9 @@ describe('Editors component', () => { describe('toggleEditorOption()', () => { const filename = MAIN_JS; - it('handles an error', () => { + it('handles an error', async () => { const editor = new MonacoEditorMock(); - editorMosaic.addEditor(filename, editor as unknown as Editor); + await editorMosaic.addEditor(filename, editor as unknown as Editor); editor.updateOptions.mockImplementationOnce(() => { throw new Error('Bwap bwap'); }); @@ -76,11 +77,11 @@ describe('Editors component', () => { expect(instance.toggleEditorOption('wordWrap')).toBe(false); }); - it('updates a setting', () => { + it('updates a setting', async () => { const { instance } = renderEditors(); const editor = new MonacoEditorMock(); - editorMosaic.addEditor(filename, editor as unknown as Editor); + await editorMosaic.addEditor(filename, editor as unknown as Editor); expect(instance.toggleEditorOption('wordWrap')).toBe(true); expect(editor.updateOptions).toHaveBeenCalledWith({ minimap: { enabled: false }, @@ -105,9 +106,10 @@ describe('Editors component', () => { ]; for (const toolbarTitle of toolbarTitles) { - expect( - toolbars.find((toolbar) => toolbar.textContent?.includes(toolbarTitle)), - ).toBeInTheDocument(); + const el = toolbars.find((toolbar) => + toolbar.textContent?.includes(toolbarTitle), + ); + expect(el).toBeInTheDocument(); } }); @@ -251,10 +253,10 @@ describe('Editors component', () => { expect(editor.setSelection).toHaveBeenCalledWith(range); }); - it('handles the monaco editor option event', () => { + it('handles the monaco editor option event', async () => { const id = MAIN_JS; const editor = new MonacoEditorMock(); - editorMosaic.addEditor(id, editor as unknown as Editor); + await editorMosaic.addEditor(id, editor as unknown as Editor); renderEditors(); emitEvent('toggle-monaco-option', 'wordWrap'); diff --git a/tests/renderer/components/sidebar-file-tree-spec.tsx b/rtl-spec/components/sidebar-file-tree.spec.tsx similarity index 96% rename from tests/renderer/components/sidebar-file-tree-spec.tsx rename to rtl-spec/components/sidebar-file-tree.spec.tsx index 9dcdcaa165..60703cfa94 100644 --- a/tests/renderer/components/sidebar-file-tree-spec.tsx +++ b/rtl-spec/components/sidebar-file-tree.spec.tsx @@ -9,16 +9,13 @@ import { MAIN_CJS, MAIN_JS, PACKAGE_NAME, -} from '../../../src/interfaces'; -import { Editors } from '../../../src/renderer/components/editors'; -import { SidebarFileTree } from '../../../src/renderer/components/sidebar-file-tree'; -import { - EditorMosaic, - EditorPresence, -} from '../../../src/renderer/editor-mosaic'; -import { AppState } from '../../../src/renderer/state'; -import { createEditorValues } from '../../mocks/editor-values'; -import { AppMock, StateMock } from '../../mocks/mocks'; +} from '../../src/interfaces'; +import { Editors } from '../../src/renderer/components/editors'; +import { SidebarFileTree } from '../../src/renderer/components/sidebar-file-tree'; +import { EditorMosaic, EditorPresence } from '../../src/renderer/editor-mosaic'; +import { AppState } from '../../src/renderer/state'; +import { createEditorValues } from '../../tests/mocks/editor-values'; +import { AppMock, StateMock } from '../../tests/mocks/mocks'; describe('SidebarFileTree component', () => { let store: AppState; From 165cd158fcd0c916b500b0ed6765b74c1261d2f7 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 18:56:33 -0800 Subject: [PATCH 09/19] fix all tests --- .../sidebar-file-tree.spec.tsx.snap | 1121 +++++++++++++++++ rtl-spec/components/editor.spec.tsx | 12 +- tests/renderer/app-spec.tsx | 4 +- tests/renderer/editor-mosaic-spec.ts | 6 +- tests/renderer/state-spec.ts | 10 +- vitest.config.ts | 2 +- 6 files changed, 1139 insertions(+), 16 deletions(-) create mode 100644 rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap diff --git a/rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap b/rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap new file mode 100644 index 0000000000..13e44f6ae8 --- /dev/null +++ b/rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap @@ -0,0 +1,1121 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SidebarFileTree component > reflects the visibility state of all icons 1`] = ` +
    +
    +
    +
      +
    • +
      + + + + Editors + + +
      + + + + + + +
      +
      +
      +
      +
      +
        +
      • +
        + + + +
        + index.html +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + main.js +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + preload.js +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + renderer.js +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + styles.css +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      +
      +
      +
    • +
    +
    +
    +
    +`; + +exports[`SidebarFileTree component > renders 1`] = ` +
    +
    +
    +
      +
    • +
      + + + + Editors + + +
      + + + + + + +
      +
      +
      +
      +
      +
        +
      • +
        + + + +
        + index.html +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + main.js +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + preload.js +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + renderer.js +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      • +
        + + + +
        + styles.css +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
        +
      • +
      +
      +
      +
    • +
    +
    +
    +
    +`; diff --git a/rtl-spec/components/editor.spec.tsx b/rtl-spec/components/editor.spec.tsx index 0aeff1805c..5c8c357418 100644 --- a/rtl-spec/components/editor.spec.tsx +++ b/rtl-spec/components/editor.spec.tsx @@ -27,13 +27,13 @@ describe('Editor component', () => { }); } - function initializeEditorMosaic(id: EditorId) { - store.editorMosaic.set({ [id]: '// content' }); + async function initializeEditorMosaic(id: EditorId) { + await store.editorMosaic.set({ [id]: '// content' }); } - it('renders the editor container', () => { + it('renders the editor container', async () => { const id = MAIN_JS; - initializeEditorMosaic(id); + await initializeEditorMosaic(id); const { renderResult } = createEditor(id); @@ -67,13 +67,13 @@ describe('Editor component', () => { it('calls editorMosaic.addEditor', async () => { const id = MAIN_JS; const { editorMosaic } = store; - editorMosaic.set({ [id]: '// content' }); + await editorMosaic.set({ [id]: '// content' }); const addEditorSpy = vi.spyOn(editorMosaic, 'addEditor'); const didMount = vi.fn(); createEditor(id, didMount); - expect(didMount).toHaveBeenCalled(); + await vi.waitFor(() => didMount.mock.calls.length > 0); expect(addEditorSpy).toHaveBeenCalledWith(id, expect.anything()); }); diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx index db8c64e687..aa7b03b00c 100644 --- a/tests/renderer/app-spec.tsx +++ b/tests/renderer/app-spec.tsx @@ -26,7 +26,7 @@ describe('App component', () => { document.body.innerHTML = '
    '; }); - beforeEach(() => { + beforeEach(async () => { vi.mocked(window.ElectronFiddle.getTemplate).mockResolvedValue({ [MAIN_JS]: '// content', }); @@ -51,7 +51,7 @@ describe('App component', () => { }); window.app = app; - state.editorMosaic.set({ [MAIN_JS]: '// content' }); + await state.editorMosaic.set({ [MAIN_JS]: '// content' }); state.editorMosaic.files.set(MAIN_JS, EditorPresence.Pending); }); diff --git a/tests/renderer/editor-mosaic-spec.ts b/tests/renderer/editor-mosaic-spec.ts index f910a11b27..cb428fb5a9 100644 --- a/tests/renderer/editor-mosaic-spec.ts +++ b/tests/renderer/editor-mosaic-spec.ts @@ -206,7 +206,7 @@ describe('EditorMosaic', () => { for (const [file, value] of Object.entries(values)) { const editor = new MonacoEditorMock() as unknown as Editor; await editorMosaic.addEditor(file as EditorId, editor); - editor.setValue(value as string); + editor.setValue(value); } // values() should match the modified values @@ -247,8 +247,8 @@ describe('EditorMosaic', () => { describe('set()', () => { it('resets isEdited to false', async () => { await editorMosaic.set(valuesIn); - editorMosaic.clearSaved(); - expect(editorMosaic.isEdited).toBe(true); + editor.setValue('beep boop'); + vi.waitFor(() => editorMosaic.isEdited === true); await editorMosaic.set(createEditorValues()); expect(editorMosaic.isEdited).toBe(false); diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index 1282e68959..d4bff37084 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -470,8 +470,6 @@ describe('AppState', () => { it('if the current fiddle is an unedited template', async () => { appState.templateName = oldVersion; await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); - (appState.editorMosaic as any).savedHash = 'saved'; - (appState.editorMosaic as any).currentHash = 'current'; await appState.setVersion(newVersion); const templateName = newVersion; @@ -480,8 +478,12 @@ describe('AppState', () => { it('but not if the current fiddle is edited', async () => { await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); - (appState.editorMosaic as any).savedHash = 'saved'; - (appState.editorMosaic as any).currentHash = 'current'; + (appState.editorMosaic as any).savedHashes = new Map([ + [MAIN_JS, 'saved'], + ]); + (appState.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'different'], + ]); appState.templateName = oldVersion; await appState.setVersion(newVersion); diff --git a/vitest.config.ts b/vitest.config.ts index a4c81f75bf..62886d086a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { environment: 'jsdom', globalSetup: 'tests/globalSetup.ts', - include: ['**/rtl-spec/**/*.spec.*', '**/tests/**/*-spec.{ts,tsx}'], + include: ['**/rtl-spec/**/*.spec.{ts,tsx}', '**/tests/**/*-spec.{ts,tsx}'], setupFiles: ['tests/setup.ts'], snapshotSerializers: ['enzyme-to-json/serializer'], }, From 495dfdac40bc76bd4fb53ed86643518b32081454 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 19:02:06 -0800 Subject: [PATCH 10/19] fix publish button test too --- rtl-spec/components/commands-publish-button.spec.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/rtl-spec/components/commands-publish-button.spec.tsx b/rtl-spec/components/commands-publish-button.spec.tsx index 4b3ac2574b..e034f191f7 100644 --- a/rtl-spec/components/commands-publish-button.spec.tsx +++ b/rtl-spec/components/commands-publish-button.spec.tsx @@ -163,7 +163,15 @@ describe('Action button component', () => { expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); }); - it.todo('marks the Fiddle as saved', async () => {}); + it('marks the Fiddle as saved', async () => { + (state.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'abc123'], + ]); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); + expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); + expect(state.editorMosaic.isEdited).toBe(false); + }); it('asks the user for a description', async () => { const description = 'some non-default description'; @@ -221,7 +229,7 @@ describe('Action button component', () => { }); }); - it.todo('handles an error in Gist publishing', async () => { + it('handles an error in Gist publishing', async () => { mocktokit.gists.create.mockImplementation(() => { throw new Error(errorMessage); }); From 4a4c159fff75aa8bf1e7fd9e9881de46d716a044 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 19:04:30 -0800 Subject: [PATCH 11/19] remove snapshots --- .../sidebar-file-tree.spec.tsx.snap | 1121 ----------------- .../components/sidebar-file-tree.spec.tsx | 10 +- 2 files changed, 3 insertions(+), 1128 deletions(-) delete mode 100644 rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap diff --git a/rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap b/rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap deleted file mode 100644 index 13e44f6ae8..0000000000 --- a/rtl-spec/components/__snapshots__/sidebar-file-tree.spec.tsx.snap +++ /dev/null @@ -1,1121 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`SidebarFileTree component > reflects the visibility state of all icons 1`] = ` -
    -
    -
    -
      -
    • -
      - - - - Editors - - -
      - - - - - - -
      -
      -
      -
      -
      -
        -
      • -
        - - - -
        - index.html -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - main.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - preload.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - renderer.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - styles.css -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      -
      -
      -
    • -
    -
    -
    -
    -`; - -exports[`SidebarFileTree component > renders 1`] = ` -
    -
    -
    -
      -
    • -
      - - - - Editors - - -
      - - - - - - -
      -
      -
      -
      -
      -
        -
      • -
        - - - -
        - index.html -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - main.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - preload.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - renderer.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - styles.css -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      -
      -
      -
    • -
    -
    -
    -
    -`; diff --git a/rtl-spec/components/sidebar-file-tree.spec.tsx b/rtl-spec/components/sidebar-file-tree.spec.tsx index 60703cfa94..922df2eb12 100644 --- a/rtl-spec/components/sidebar-file-tree.spec.tsx +++ b/rtl-spec/components/sidebar-file-tree.spec.tsx @@ -39,17 +39,13 @@ describe('SidebarFileTree component', () => { vi.useRealTimers(); }); - it('renders', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - it('reflects the visibility state of all icons', () => { editorMosaic.hide('index.html'); const { container } = render(); - // snapshot has an 'eye-off' icon - expect(container).toMatchSnapshot(); + // Check that an 'eye-off' icon is present for the hidden file + const eyeOffIcon = container.querySelector('button .bp3-icon-eye-off'); + expect(eyeOffIcon).toBeInTheDocument(); }); it('can bring up the Add File input', async () => { From 227a95ece028ac3fea7210474e15fb1aa613cc43 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 19:23:02 -0800 Subject: [PATCH 12/19] missing await --- src/renderer/components/commands-action-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index 249ca73f67..f44be46c43 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -214,7 +214,7 @@ export const GistActionButton = observer( files, }); - appState.editorMosaic.markAsSaved(); + await appState.editorMosaic.markAsSaved(); console.log('Updating: Updating done', { gist }); if (!silent) { From 32fd3586a8218cc2ed832d4f7a7649db057c3c07 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 19:25:54 -0800 Subject: [PATCH 13/19] delete unused snapshot --- .../sidebar-file-tree-spec.tsx.snap | 1121 ----------------- 1 file changed, 1121 deletions(-) delete mode 100644 tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap diff --git a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap b/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap deleted file mode 100644 index 13e44f6ae8..0000000000 --- a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap +++ /dev/null @@ -1,1121 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`SidebarFileTree component > reflects the visibility state of all icons 1`] = ` -
    -
    -
    -
      -
    • -
      - - - - Editors - - -
      - - - - - - -
      -
      -
      -
      -
      -
        -
      • -
        - - - -
        - index.html -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - main.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - preload.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - renderer.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - styles.css -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      -
      -
      -
    • -
    -
    -
    -
    -`; - -exports[`SidebarFileTree component > renders 1`] = ` -
    -
    -
    -
      -
    • -
      - - - - Editors - - -
      - - - - - - -
      -
      -
      -
      -
      -
        -
      • -
        - - - -
        - index.html -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - main.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - preload.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - renderer.js -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      • -
        - - - -
        - styles.css -
        -
        - -
        - - - -
        -
        -
        -
        -
        -
        -
      • -
      -
      -
      -
    • -
    -
    -
    -
    -`; From bfbfd83fa23cc8f527f0a8dd94847c15a7bfa35a Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 19:36:46 -0800 Subject: [PATCH 14/19] delete stale comment --- src/renderer/editor-mosaic.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index bc251ff4df..5bdbc1a085 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -166,8 +166,6 @@ export class EditorMosaic { const language = monacoLanguage(id); const model = monaco.editor.createModel(value, language); - // if we have an editor available, use the monaco model now. - // otherwise, save the file in `this.backups` for future use. const backup: EditorBackup = { model }; this.backups.set(id, backup); From ac4cbb079fddebb77a26f1b30297065c63b40503 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 19:48:47 -0800 Subject: [PATCH 15/19] fix debounce bug --- src/renderer/editor-mosaic.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index 5bdbc1a085..79bd5bd4c4 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -97,6 +97,8 @@ export class EditorMosaic { () => this.mosaic, () => this.layout(), ); + + this.layout = this.layout.bind(this); } /** File is visible, focus file content */ @@ -330,17 +332,15 @@ export class EditorMosaic { } /// misc utilities - private layoutDebounce: ReturnType | undefined; public layout() { - const DEBOUNCE_MSEC = 50; - if (!this.layoutDebounce) { - this.layoutDebounce = setTimeout(() => { - for (const editor of this.editors.values()) editor.layout(); - delete this.layoutDebounce; - }, DEBOUNCE_MSEC); - } + clearTimeout(this.layoutDebounce); + this.layoutDebounce = setTimeout(() => { + for (const editor of this.editors.values()) { + editor.layout(); + } + }, 50); } public getAllEditors(): Editor[] { From 10ec2001ace5b9e245388592d1312ed0b67a661b Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Nov 2025 20:41:40 -0800 Subject: [PATCH 16/19] remove unused code --- tests/renderer/app-spec.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx index aa7b03b00c..63830081fd 100644 --- a/tests/renderer/app-spec.tsx +++ b/tests/renderer/app-spec.tsx @@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorValues, MAIN_JS, SetFiddleOptions } from '../../src/interfaces'; import { App } from '../../src/renderer/app'; -import { EditorMosaic, EditorPresence } from '../../src/renderer/editor-mosaic'; +import { EditorPresence } from '../../src/renderer/editor-mosaic'; import { defaultDark, defaultLight } from '../../src/themes-defaults'; import { createEditorValues } from '../mocks/mocks'; @@ -388,11 +388,6 @@ describe('App component', () => { // make a second fiddle that differs from the first const editorValues = createEditorValues(); const editorValues2: EditorValues = { [MAIN_JS]: '// hello world' }; - let editorMosaic: EditorMosaic; - - beforeEach(() => { - ({ editorMosaic } = app.state); - }); async function testDialog(confirm: boolean) { const localPath = '/etc/passwd'; From e06bf79df5f3a381c1028acbc7be9d7fc5c8dd6d Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Tue, 18 Nov 2025 09:57:53 -0800 Subject: [PATCH 17/19] move hack to `replaceFiddle` call instead of `set` --- src/renderer/app.tsx | 11 +++++++++++ src/renderer/editor-mosaic.ts | 11 ----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 783f7d4169..ec6ad8b616 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -69,6 +69,17 @@ export class App { await this.state.editorMosaic.set(editorValues); + // HACK: editors should be mounted shortly after we load something. + // We could try waiting for every single `editorDidMount` callback + // to fire, but that gets complicated with recycled editors with changed + // values. This is just easier for now. + await new Promise((resolve) => + setTimeout(async () => { + await this.state.editorMosaic.markAsSaved(); + resolve(); + }, 100), + ); + this.state.gistId = gistId || ''; this.state.localPath = localFiddle?.filePath; this.state.templateName = templateName; diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index 79bd5bd4c4..832cd361dc 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -133,17 +133,6 @@ export class EditorMosaic { for (const id of this.editors.keys()) { if (!values.has(id)) this.editors.delete(id); } - - // HACK: editors should be mounted shortly after we load something. - // We could try waiting for every single `editorDidMount` callback - // to fire, but that gets complicated with recycled editors with changed - // values. This is just easier for now. - await new Promise((resolve) => - setTimeout(async () => { - await this.markAsSaved(); - resolve(); - }, 100), - ); } /** Add a file. If we already have a file with that name, replace it. */ From 8487ec9cbde20c9821479c02602f394daf8ac6f6 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Tue, 18 Nov 2025 14:11:52 -0800 Subject: [PATCH 18/19] fix up tests --- tests/renderer/app-spec.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx index 63830081fd..ea96ce6af1 100644 --- a/tests/renderer/app-spec.tsx +++ b/tests/renderer/app-spec.tsx @@ -439,15 +439,16 @@ describe('App component', () => { // Actually set the editor in a dirty state instead of mocking // because this code path uses an autorun on the computed `isEdited` value + (app.state.editorMosaic as any).savedHashes = new Map([[MAIN_JS, 'def']]); (app.state.editorMosaic as any).currentHashes = new Map([ [MAIN_JS, 'abc'], ]); - expect(window.onbeforeunload).toBeTruthy(); - const e = { returnValue: Boolean, }; + + await vi.waitUntil(() => app.state.editorMosaic.isEdited === true); window.onbeforeunload!(e as any); expect(e.returnValue).toBe(false); @@ -463,9 +464,11 @@ describe('App component', () => { // Actually set the editor in a dirty state instead of mocking // because this code path uses an autorun on the computed `isEdited` value + (app.state.editorMosaic as any).savedHashes = new Map([[MAIN_JS, 'def']]); (app.state.editorMosaic as any).currentHashes = new Map([ [MAIN_JS, 'abc'], ]); + expect(window.onbeforeunload).toBeTruthy(); const e = { From 12ce68d16828bd91a96ece14d218a3f6c33e7834 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Wed, 10 Dec 2025 15:51:24 -0800 Subject: [PATCH 19/19] fix --- src/renderer/app.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index c776a2b7da..cbb28d2dda 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -67,6 +67,9 @@ export class App { return false; } + await this.state.editorMosaic.set(editorValues); + this.state.editorMosaic.editorSeverityMap.clear(); + // HACK: editors should be mounted shortly after we load something. // We could try waiting for every single `editorDidMount` callback // to fire, but that gets complicated with recycled editors with changed @@ -77,8 +80,6 @@ export class App { resolve(); }, 100), ); - await this.state.editorMosaic.set(editorValues); - this.state.editorMosaic.editorSeverityMap.clear(); this.state.gistId = gistId || ''; this.state.localPath = localFiddle?.filePath;