From 820111fe4aacfb85a9da52ea78985e797b16c4bb Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:41:54 +0200 Subject: [PATCH 1/3] fix(tasks): preserve dirty diff edits --- .../features/tasks/editor/editor-provider.tsx | 4 +- .../stores/file-model-lifecycle-store.ts | 18 +++-- .../tasks/tabs/tab-group-manager-store.ts | 11 +++ .../tasks/tabs/tab-manager-store.test.ts | 70 +++++++++++++++++++ .../features/tasks/tabs/tab-manager-store.ts | 39 ++++++++++- .../tasks/view/tab-bar/diff-tab-item.tsx | 8 ++- 6 files changed, 138 insertions(+), 12 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-provider.tsx b/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-provider.tsx index 438f979641..2b18abd262 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-provider.tsx @@ -104,7 +104,7 @@ export const EditorProvider = observer(function EditorProvider({ addMonacoKeyboardShortcuts(editor, m, { onSave: () => { - const path = paneTabManager.activeFilePath; + const path = paneTabManager.activeEditablePath; if (path) void editorView.saveFile(path); }, onSaveAll: () => { @@ -159,7 +159,7 @@ export const EditorProvider = observer(function EditorProvider({ return; } - const path = paneTabManager.activeFilePath; + const path = paneTabManager.activeEditablePath; if (!path) return; event.preventDefault(); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts index a4ee156148..3aab8f229d 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts @@ -1,5 +1,6 @@ import { computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import type { TabGroupManagerStore } from '@renderer/features/tasks/tabs/tab-group-manager-store'; +import { getEditableBufferPath } from '@renderer/features/tasks/tabs/tab-manager-store'; import { getFileKind } from '@renderer/lib/editor/fileKind'; import { rpc } from '@renderer/lib/ipc'; import { showModal } from '@renderer/lib/modal/modal-provider'; @@ -52,11 +53,13 @@ export class FileModelLifecycleStore implements Snapshottable this.tabGroupManager.allOpenFilePaths, + () => this.tabGroupManager.allOpenEditablePaths, (current, previous = []) => { const prev = new Set(previous); const curr = new Set(current); @@ -82,10 +85,11 @@ export class FileModelLifecycleStore implements Snapshottable { - const dirtyPaths = this.openFilePaths.filter((path) => + const dirtyPaths = this.tabGroupManager.allOpenEditablePaths.filter((path) => modelRegistry.isDirty(buildMonacoModelPath(this.modelRootPath, path)) ); for (const path of dirtyPaths) { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts index 9406165305..cf6a3c0213 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts @@ -56,6 +56,7 @@ export class TabGroupManagerStore { paneSizes: observable, focusedGroup: computed, allOpenFilePaths: computed, + allOpenEditablePaths: computed, registerCloseHandler: action, splitRight: action, openConversationInRightSplit: action, @@ -88,6 +89,16 @@ export class TabGroupManagerStore { return [...seen]; } + get allOpenEditablePaths(): string[] { + const seen = new Set(); + for (const { tabManager } of this.groups) { + for (const path of tabManager.openEditablePaths) { + seen.add(path); + } + } + return [...seen]; + } + registerCloseHandler(handler: (tabId: string) => Promise): void { this._closeHandler = handler; for (const { tabManager } of this.groups) { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.test.ts index 8e1036472f..d6eb585c6b 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { browserDiagnosticsStore } from '@renderer/features/browser/browser-diagnostics-store'; import { browserSessionStore } from '@renderer/features/browser/browser-session-store'; import { events } from '@renderer/lib/ipc'; +import { modelRegistry } from '@renderer/lib/monaco/monaco-model-registry'; +import { buildMonacoModelPath } from '@renderer/lib/monaco/monacoModelPath'; +import type { GitObjectRef } from '@shared/core/git/git'; import { browserOpenInNewTabChannel } from '@shared/events/browserEvents'; import { TabManagerStore } from './tab-manager-store'; @@ -37,9 +40,24 @@ function createTabManager(): TabManagerStore { return new TabManagerStore(() => null, 'workspace-1', 'project-1', 'task-1'); } +const ORIGINAL_REF: GitObjectRef = { kind: 'commit', sha: 'abc123' }; + +function workingTreeDiff(path = 'src/example.ts') { + return { + path, + type: 'disk' as const, + group: 'disk' as const, + originalRef: ORIGINAL_REF, + }; +} + describe('TabManagerStore browser tabs', () => { beforeEach(() => { vi.clearAllMocks(); + modelRegistry.dirtyUris.clear(); + vi.mocked(modelRegistry.isDirty).mockImplementation((uri: string) => + modelRegistry.dirtyUris.has(uri) + ); browserDiagnosticsStore.clear(); browserSessionStore.clear(); }); @@ -169,3 +187,55 @@ describe('TabManagerStore browser tabs', () => { ).toBe('https://target.example/path'); }); }); + +describe('TabManagerStore diff tabs', () => { + beforeEach(() => { + vi.clearAllMocks(); + modelRegistry.dirtyUris.clear(); + vi.mocked(modelRegistry.isDirty).mockImplementation((uri: string) => + modelRegistry.dirtyUris.has(uri) + ); + }); + + it('exposes working-tree diff tabs as editable buffers', () => { + const manager = createTabManager(); + const activeFile = workingTreeDiff(); + + manager.openDiff(activeFile); + const bufferUri = buildMonacoModelPath('workspace:workspace-1', activeFile.path); + modelRegistry.dirtyUris.add(bufferUri); + + expect(manager.activeEditablePath).toBe(activeFile.path); + expect(manager.openEditablePaths).toEqual([activeFile.path]); + expect(manager.resolvedTabs[0]).toMatchObject({ + kind: 'diff', + path: activeFile.path, + isDirty: true, + bufferUri, + }); + }); + + it('keeps a dirty working-tree diff preview instead of replacing it', () => { + const manager = createTabManager(); + const first = workingTreeDiff('src/first.ts'); + const second = workingTreeDiff('src/second.ts'); + + manager.openDiffPreview(first); + modelRegistry.dirtyUris.add(buildMonacoModelPath('workspace:workspace-1', first.path)); + manager.openDiffPreview(second); + + expect(manager.resolvedTabs).toHaveLength(2); + expect(manager.resolvedTabs[0]).toMatchObject({ + kind: 'diff', + path: first.path, + isPreview: false, + isDirty: true, + }); + expect(manager.resolvedTabs[1]).toMatchObject({ + kind: 'diff', + path: second.path, + isPreview: true, + isDirty: false, + }); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts index 2b700e6ea9..0bda81f63c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts @@ -75,6 +75,12 @@ export class BrowserTabEntry { export type TabEntry = FileTabStore | DiffTabStore | ConversationTabEntry | BrowserTabEntry; +export function getEditableBufferPath(entry: TabEntry): string | null { + if (entry.kind === 'file') return entry.isExternal ? null : entry.path; + if (entry.kind === 'diff') return entry.diffGroup === 'disk' ? entry.path : null; + return null; +} + function optionalRefsEqual(left: GitObjectRef | undefined, right: GitObjectRef | undefined) { if (left === undefined || right === undefined) return left === right; return refsEqual(left, right); @@ -117,6 +123,8 @@ export type ResolvedDiffTab = { kind: 'diff'; tabId: string; path: string; + isDirty: boolean; + bufferUri: string; diffGroup: 'disk' | 'staged' | 'git' | 'pr'; originalRef: GitObjectRef; modifiedRef?: GitObjectRef; @@ -189,10 +197,12 @@ export class TabManagerStore implements Snapshottable { activeConversationId: computed, activeFileEntry: computed, activeFilePath: computed, + activeEditablePath: computed, activeDiffEntry: computed, previewFileEntry: computed, previewDiffEntry: computed, openFilePaths: computed, + openEditablePaths: computed, resolvedTabs: computed, snapshot: computed, openConversation: action, @@ -315,6 +325,11 @@ export class TabManagerStore implements Snapshottable { return this.activeFileEntry?.path ?? null; } + get activeEditablePath(): string | null { + const entry = this.activeDescriptor; + return entry ? getEditableBufferPath(entry) : null; + } + get activeDiffEntry(): DiffTabStore | undefined { const desc = this.activeDescriptor; return desc?.kind === 'diff' ? desc : undefined; @@ -351,6 +366,17 @@ export class TabManagerStore implements Snapshottable { return paths; } + get openEditablePaths(): string[] { + const paths: string[] = []; + for (const id of this.tabOrder) { + const entry = this.entries.get(id); + if (!entry) continue; + const path = getEditableBufferPath(entry); + if (path) paths.push(path); + } + return paths; + } + get resolvedTabs(): ResolvedTab[] { const result: ResolvedTab[] = []; const effectiveActiveId = this.resolvedActiveTabId; @@ -381,10 +407,13 @@ export class TabManagerStore implements Snapshottable { isActive: effectiveActiveId === entry.tabId, }); } else if (entry.kind === 'diff') { + const bufferUri = buildMonacoModelPath(this.modelRootPath, entry.path); result.push({ kind: 'diff', tabId: entry.tabId, path: entry.path, + isDirty: getEditableBufferPath(entry) !== null && modelRegistry.isDirty(bufferUri), + bufferUri, diffGroup: entry.diffGroup, originalRef: entry.originalRef, modifiedRef: entry.modifiedRef, @@ -406,7 +435,7 @@ export class TabManagerStore implements Snapshottable { tabId: entry.tabId, path: entry.path, isPreview: entry.isPreview, - isDirty: entry.isExternal ? false : modelRegistry.dirtyUris.has(bufferUri), + isDirty: getEditableBufferPath(entry) !== null && modelRegistry.isDirty(bufferUri), bufferUri, isActive: effectiveActiveId === entry.tabId, isExternal: entry.isExternal, @@ -612,7 +641,11 @@ export class TabManagerStore implements Snapshottable { } const previewEntry = this.previewDiffEntry; - if (previewEntry) { + const previewPath = previewEntry ? getEditableBufferPath(previewEntry) : null; + const previewUri = previewPath ? buildMonacoModelPath(this.modelRootPath, previewPath) : null; + const canReplace = previewEntry && (!previewUri || !modelRegistry.isDirty(previewUri)); + + if (canReplace && previewEntry) { // Replace preview in-place: remove old, insert new at same position. const idx = this.tabOrder.indexOf(previewEntry.tabId); this.entries.delete(previewEntry.tabId); @@ -623,6 +656,8 @@ export class TabManagerStore implements Snapshottable { return; } + if (previewEntry) previewEntry.isPreview = false; + const tab = new DiffTabStore(activeFile, true, undefined, status); this.entries.set(tab.tabId, tab); addTabId(this, tab.tabId); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx index 0d30faadb7..4e27826f5a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx @@ -32,6 +32,7 @@ export const DiffTabItem = observer(function DiffTabItem({ }) { const fileName = tab.path.split('/').pop() ?? 'Untitled'; const suffix = diffGroupSuffix(tab.diffGroup); + const hasUnsavedChanges = tab.isDirty; return ( + ) : tab.status ? ( From a5cd3bcb05e87bad48744b5da94c90d9f1222da9 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:01:03 +0200 Subject: [PATCH 2/3] fix(diff): preserve buffers for staged tabs --- .../stores/file-model-lifecycle-store.ts | 16 ++++++++++- .../tasks/tabs/tab-manager-store.test.ts | 27 +++++++++++++++++++ .../features/tasks/tabs/tab-manager-store.ts | 7 +++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts index 3aab8f229d..6041182939 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts @@ -313,6 +313,20 @@ export class FileModelLifecycleStore implements Snapshottable { beforeEach(() => { vi.clearAllMocks(); @@ -238,4 +247,22 @@ describe('TabManagerStore diff tabs', () => { isDirty: false, }); }); + + it('does not expose buffer URIs for non-editable diff tabs', () => { + const manager = createTabManager(); + const activeFile = stagedDiff(); + const bufferUri = buildMonacoModelPath('workspace:workspace-1', activeFile.path); + + manager.openDiff(activeFile); + modelRegistry.dirtyUris.add(bufferUri); + + expect(manager.activeEditablePath).toBeNull(); + expect(manager.openEditablePaths).toEqual([]); + expect(manager.resolvedTabs[0]).toMatchObject({ + kind: 'diff', + path: activeFile.path, + isDirty: false, + bufferUri: '', + }); + }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts index 0bda81f63c..58c7ed3df4 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts @@ -407,12 +407,15 @@ export class TabManagerStore implements Snapshottable { isActive: effectiveActiveId === entry.tabId, }); } else if (entry.kind === 'diff') { - const bufferUri = buildMonacoModelPath(this.modelRootPath, entry.path); + const editablePath = getEditableBufferPath(entry); + const bufferUri = editablePath + ? buildMonacoModelPath(this.modelRootPath, editablePath) + : ''; result.push({ kind: 'diff', tabId: entry.tabId, path: entry.path, - isDirty: getEditableBufferPath(entry) !== null && modelRegistry.isDirty(bufferUri), + isDirty: editablePath !== null && modelRegistry.isDirty(bufferUri), bufferUri, diffGroup: entry.diffGroup, originalRef: entry.originalRef, From 6fa31f5ae4b31b2fbf0c90dcb3d8ab69eb0cb971 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:10:28 +0200 Subject: [PATCH 3/3] fix(diff): clear buffers after discard --- .../tasks/editor/stores/file-model-lifecycle-store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts index 6041182939..2cc260cecc 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/file-model-lifecycle-store.ts @@ -314,15 +314,15 @@ export class FileModelLifecycleStore implements Snapshottable