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..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 @@ -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,89 @@ 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('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 () => { 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).