From 2466ffd09dd0cde094fd77416779e82ea60b0347 Mon Sep 17 00:00:00 2001 From: george-vii Date: Tue, 9 Jun 2026 17:29:01 +0700 Subject: [PATCH 1/2] fix(editor): refresh rendered preview after a disk-driven model reload Discarding a change (or any working-tree revert the fs-watcher surfaces) reloaded the buffer model's content but left an open rendered preview showing the old content until the tab was remounted. Disk-driven buffer reloads (applyDiskUpdate, reloadFromDisk) update the Monaco model inside a reloadingFromDisk guard, which makes the buffer's change listener bail out and skip bumping bufferVersions. Read-only reactive consumers (the rendered markdown/html/svg preview) subscribe to bufferVersions, so they never re-rendered. Both reload paths now bump bufferVersions after applying the reloaded content. Adds a regression test: a disk reload updates a clean open buffer and bumps bufferVersions. --- .../lib/monaco/monaco-model-registry.test.ts | 42 ++++++++++++++++++- .../lib/monaco/monaco-model-registry.ts | 12 ++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts index 99c9a490a..e97463c2a 100644 --- a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts +++ b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts @@ -5,6 +5,7 @@ import { MonacoModelRegistry } from './monaco-model-registry'; const rpcState = vi.hoisted(() => ({ indexContent: 'base' as string | null, refContent: 'base' as string | null, + diskContent: 'base' as string, })); vi.mock('@renderer/lib/ipc', () => ({ @@ -13,7 +14,7 @@ vi.mock('@renderer/lib/ipc', () => ({ fs: { readFile: vi.fn(async () => ({ success: true, - data: { content: 'base', truncated: false, totalSize: 4 }, + data: { content: rpcState.diskContent, truncated: false, totalSize: 4 }, })), }, git: { @@ -105,6 +106,45 @@ describe('MonacoModelRegistry', () => { beforeEach(() => { rpcState.indexContent = 'base'; rpcState.refContent = 'base'; + rpcState.diskContent = 'base'; + }); + + it('bumps bufferVersions when a disk reload updates a clean open buffer', async () => { + // Regression: a disk-driven reload (e.g. after discard) updates the buffer + // model's content, but the onDidChangeContent listener bails while + // reloadingFromDisk is set and never bumps bufferVersions. Read-only reactive + // consumers (the rendered markdown/html/svg preview) subscribe to + // bufferVersions, so without an explicit bump they stay stale until remount. + const registry = new MonacoModelRegistry(); + registry.notifyMonacoReady(makeFakeMonaco() as never); + + const projectId = 'project'; + const workspaceId = 'workspace'; + const root = `workspace:${workspaceId}`; + const filePath = 'README.md'; + const language = 'markdown'; + + rpcState.diskContent = 'base'; + const uri = await registry.registerModel( + projectId, + workspaceId, + root, + filePath, + language, + 'disk' + ); + await registry.registerModel(projectId, workspaceId, root, filePath, language, 'buffer'); + const diskUri = registry.toDiskUri(uri); + + expect(registry.getValue(uri)).toBe('base'); + const versionBefore = registry.bufferVersions.get(uri) ?? 0; + + // Simulate discard / external revert: disk now holds the reverted content. + rpcState.diskContent = 'reverted'; + await registry.invalidateModel(diskUri); + + expect(registry.getValue(uri)).toBe('reverted'); + expect(registry.bufferVersions.get(uri) ?? 0).toBeGreaterThan(versionBefore); }); it('clears a staged git model when the index no longer contains the file', async () => { diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts index 9c5de8e3e..37dffb958 100644 --- a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts +++ b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts @@ -711,6 +711,10 @@ export class MonacoModelRegistry { this.reloadingFromDisk.delete(uri); runInAction(() => { this.dirtyUris.delete(uri); + // setValue fires onDidChangeContent, but it bails while reloadingFromDisk is + // set and never bumps bufferVersions — bump it here so preview renderers + // re-render (same reasoning as applyDiskUpdate). + this.bufferVersions.set(uri, (this.bufferVersions.get(uri) ?? 0) + 1); }); } this.pendingConflicts.delete(uri); @@ -840,6 +844,14 @@ export class MonacoModelRegistry { const fullRange = bufEntry.model.getFullModelRange(); bufEntry.model.applyEdits([{ range: fullRange, text: newContent }], false); this.reloadingFromDisk.delete(bufferUri); + // applyEdits fires onDidChangeContent, but that listener bails out while + // reloadingFromDisk is set, so it never bumps bufferVersions. Bump it here + // so observer() renderers that read buffer text (the markdown/html/svg + // preview) re-render with the reloaded content instead of staying stale + // until the tab is remounted. + runInAction(() => { + this.bufferVersions.set(bufferUri, (this.bufferVersions.get(bufferUri) ?? 0) + 1); + }); } // Clear dirty state — disk now matches buffer (either buffer was synced to disk, or // new disk content already matched existing buffer edits). From 6dbdf7b0b376bf7190e1b077e7f3494c213f07e1 Mon Sep 17 00:00:00 2001 From: george-vii Date: Wed, 10 Jun 2026 17:43:37 +0700 Subject: [PATCH 2/2] test(editor): cover the conflict-path reloadFromDisk bufferVersions bump --- .../lib/monaco/monaco-model-registry.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts index e97463c2a..baf7e1c93 100644 --- a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts +++ b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.test.ts @@ -147,6 +147,50 @@ describe('MonacoModelRegistry', () => { expect(registry.bufferVersions.get(uri) ?? 0).toBeGreaterThan(versionBefore); }); + it('bumps bufferVersions when accepting incoming disk content for a conflicted buffer', async () => { + // Same regression as above, conflict-dialog path: reloadFromDisk ("Accept + // Incoming") sets the buffer from disk while reloadingFromDisk suppresses + // the onDidChangeContent listener, so it must bump bufferVersions itself. + const registry = new MonacoModelRegistry(); + registry.notifyMonacoReady(makeFakeMonaco() as never); + + const projectId = 'project'; + const workspaceId = 'workspace'; + const root = `workspace:${workspaceId}`; + const filePath = 'README.md'; + const language = 'markdown'; + + rpcState.diskContent = 'base'; + const uri = await registry.registerModel( + projectId, + workspaceId, + root, + filePath, + language, + 'disk' + ); + await registry.registerModel(projectId, workspaceId, root, filePath, language, 'buffer'); + const diskUri = registry.toDiskUri(uri); + + // User edits the buffer, then the file changes on disk underneath them: + // applyDiskUpdate must record a conflict instead of clobbering the edit. + registry.getModelByUri(uri)?.setValue('user edit'); + expect(registry.isDirty(uri)).toBe(true); + rpcState.diskContent = 'external change'; + await registry.invalidateModel(diskUri); + + expect(registry.hasPendingConflict(uri)).toBe(true); + expect(registry.getValue(uri)).toBe('user edit'); + const versionBefore = registry.bufferVersions.get(uri) ?? 0; + + registry.reloadFromDisk(uri); + + expect(registry.getValue(uri)).toBe('external change'); + expect(registry.isDirty(uri)).toBe(false); + expect(registry.hasPendingConflict(uri)).toBe(false); + expect(registry.bufferVersions.get(uri) ?? 0).toBeGreaterThan(versionBefore); + }); + it('clears a staged git model when the index no longer contains the file', async () => { const registry = new MonacoModelRegistry(); registry.notifyMonacoReady(makeFakeMonaco() as never);