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..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 @@ -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) { @@ -309,6 +313,20 @@ export class FileModelLifecycleStore implements Snapshottable(); + 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..209ac6e85b 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,33 @@ 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, + }; +} + +function stagedDiff(path = 'src/example.ts') { + return { + path, + type: 'git' as const, + group: 'staged' 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 +196,73 @@ 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, + }); + }); + + 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 2b700e6ea9..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 @@ -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,16 @@ export class TabManagerStore implements Snapshottable { isActive: effectiveActiveId === entry.tabId, }); } else if (entry.kind === 'diff') { + const editablePath = getEditableBufferPath(entry); + const bufferUri = editablePath + ? buildMonacoModelPath(this.modelRootPath, editablePath) + : ''; result.push({ kind: 'diff', tabId: entry.tabId, path: entry.path, + isDirty: editablePath !== null && modelRegistry.isDirty(bufferUri), + bufferUri, diffGroup: entry.diffGroup, originalRef: entry.originalRef, modifiedRef: entry.modifiedRef, @@ -406,7 +438,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 +644,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 +659,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 ? (