Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -159,7 +159,7 @@ export const EditorProvider = observer(function EditorProvider({
return;
}

const path = paneTabManager.activeFilePath;
const path = paneTabManager.activeEditablePath;
if (!path) return;

event.preventDefault();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -52,11 +53,13 @@ export class FileModelLifecycleStore implements Snapshottable<EditorViewSnapshot
snapshot: computed,
});

// Reactive model lifecycle: register/unregister Monaco models as file tabs open/close
// across ALL panes. A model stays registered as long as any pane has the file open.
// Reactive model lifecycle: register/unregister Monaco models as editable tabs open/close
// across ALL panes. This includes file tabs and working-tree diff tabs so unsaved diff
// edits survive renderer unmounts when users switch tabs. A model stays registered as
// long as any pane has the editable path open.
this.disposers.push(
reaction(
() => this.tabGroupManager.allOpenFilePaths,
() => this.tabGroupManager.allOpenEditablePaths,
(current, previous = []) => {
const prev = new Set(previous);
const curr = new Set(current);
Expand All @@ -82,10 +85,11 @@ export class FileModelLifecycleStore implements Snapshottable<EditorViewSnapshot
for (const { tabManager } of tabGroupManager.groups) {
const entry = tabManager.entries.get(tabId);
if (entry !== undefined) {
if (entry.kind === 'file') {
const uri = buildMonacoModelPath(this.modelRootPath, entry.path);
const editablePath = getEditableBufferPath(entry);
if (editablePath) {
const uri = buildMonacoModelPath(this.modelRootPath, editablePath);
if (modelRegistry.isDirty(uri)) {
const result = await this._confirmClose(entry.path);
const result = await this._confirmClose(editablePath);
if (result === 'cancel') return;
}
}
Expand Down Expand Up @@ -154,7 +158,7 @@ export class FileModelLifecycleStore implements Snapshottable<EditorViewSnapshot
}

async saveAllFiles(): Promise<void> {
const dirtyPaths = this.openFilePaths.filter((path) =>
const dirtyPaths = this.tabGroupManager.allOpenEditablePaths.filter((path) =>
modelRegistry.isDirty(buildMonacoModelPath(this.modelRootPath, path))
);
for (const path of dirtyPaths) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class TabGroupManagerStore {
paneSizes: observable,
focusedGroup: computed,
allOpenFilePaths: computed,
allOpenEditablePaths: computed,
registerCloseHandler: action,
splitRight: action,
openConversationInRightSplit: action,
Expand Down Expand Up @@ -88,6 +89,16 @@ export class TabGroupManagerStore {
return [...seen];
}

get allOpenEditablePaths(): string[] {
const seen = new Set<string>();
for (const { tabManager } of this.groups) {
for (const path of tabManager.openEditablePaths) {
seen.add(path);
}
}
return [...seen];
}

registerCloseHandler(handler: (tabId: string) => Promise<void>): void {
this._closeHandler = handler;
for (const { tabManager } of this.groups) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -189,10 +197,12 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
activeConversationId: computed,
activeFileEntry: computed,
activeFilePath: computed,
activeEditablePath: computed,
activeDiffEntry: computed,
previewFileEntry: computed,
previewDiffEntry: computed,
openFilePaths: computed,
openEditablePaths: computed,
resolvedTabs: computed,
snapshot: computed,
openConversation: action,
Expand Down Expand Up @@ -315,6 +325,11 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
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;
Expand Down Expand Up @@ -351,6 +366,17 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
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;
Expand Down Expand Up @@ -381,10 +407,13 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
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,
Comment thread
janburzinski marked this conversation as resolved.
diffGroup: entry.diffGroup,
originalRef: entry.originalRef,
modifiedRef: entry.modifiedRef,
Expand All @@ -406,7 +435,7 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
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,
Expand Down Expand Up @@ -612,7 +641,11 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
}

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);
Expand All @@ -623,6 +656,8 @@ export class TabManagerStore implements Snapshottable<TabManagerSnapshot> {
return;
}

if (previewEntry) previewEntry.isPreview = false;

const tab = new DiffTabStore(activeFile, true, undefined, status);
this.entries.set(tab.tabId, tab);
addTabId(this, tab.tabId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TabItemShell
Expand All @@ -57,7 +58,12 @@ export const DiffTabItem = observer(function DiffTabItem({
onClose={onClose}
ariaLabel={`Close ${fileName} ${suffix}`}
statusIndicator={
tab.status ? (
hasUnsavedChanges ? (
<div
className="size-2 rounded-full bg-foreground group-hover:opacity-0"
title="Unsaved changes"
/>
) : tab.status ? (
<span className="transition-opacity group-hover:opacity-0">
<GitChangeStatusIcon status={tab.status} className="size-4" />
</span>
Expand Down
Loading