diff --git a/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts b/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts index ba5687d9f..3569068a1 100644 --- a/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts +++ b/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts @@ -8,7 +8,8 @@ type Entry = { }; /** - * Per-workbench cache of Monaco text models keyed by workspace-relative path. + * Per-workbench cache of Monaco text models keyed by tab model id + * (`editorTabId(workspaceId, path)`). * * A model is created once per file and reused across tab switches, so switching * tabs is `editor.setModel(existing)` instead of dispose → recreate → re-tokenize. @@ -16,8 +17,8 @@ type Entry = { * preserves each file's undo stack. The renderer keeps owning the * content-in-state contract; this registry only owns model *lifetime*. * - * Callers must `dispose(path)` when a tab closes and `disposeAll()` on workspace - * switch / unmount, otherwise detached models leak. + * Callers must `dispose(modelKey)` when a tab closes everywhere and `disposeAll()` + * on unmount, otherwise detached models leak. */ export function createMonacoModelRegistry() { const models = new Map(); @@ -39,23 +40,23 @@ export function createMonacoModelRegistry() { return { /** - * Return the cached model for `path`, creating it from `content` on first + * Return the cached model for `modelKey`, creating it from `content` on first * use. An already-cached model is returned untouched (it holds the live * edited buffer); only its language is re-applied in place when it changes. */ getOrCreate( monaco: typeof Monaco, - path: string, + modelKey: string, content: string, languageId: string, ): Monaco.editor.ITextModel { - const existing = models.get(path); + const existing = models.get(modelKey); if (existing && !existing.model.isDisposed()) { setLanguage(monaco, existing, languageId); return existing.model; } const model = monaco.editor.createModel(content, languageId); - models.set(path, { model, languageId, baseVersionId: model.getAlternativeVersionId() }); + models.set(modelKey, { model, languageId, baseVersionId: model.getAlternativeVersionId() }); return model; }, @@ -65,11 +66,11 @@ export function createMonacoModelRegistry() { */ refreshClean( monaco: typeof Monaco, - path: string, + modelKey: string, content: string, languageId: string, ): boolean { - const entry = models.get(path); + const entry = models.get(modelKey); if (!entry || entry.model.isDisposed()) return false; setLanguage(monaco, entry, languageId); if (entry.model.getAlternativeVersionId() !== entry.baseVersionId) return false; @@ -81,36 +82,36 @@ export function createMonacoModelRegistry() { }, /** Mark the current buffer as the clean baseline (after load or save). */ - markSaved(path: string): void { - const entry = models.get(path); + markSaved(modelKey: string): void { + const entry = models.get(modelKey); if (entry && !entry.model.isDisposed()) { entry.baseVersionId = entry.model.getAlternativeVersionId(); } }, /** True when the buffer has unsaved edits relative to the last save baseline. */ - isDirty(path: string): boolean { - const entry = models.get(path); + isDirty(modelKey: string): boolean { + const entry = models.get(modelKey); if (!entry || entry.model.isDisposed()) return false; return entry.model.getAlternativeVersionId() !== entry.baseVersionId; }, - /** Current buffer text, or null when no model exists for the path. */ - getValue(path: string): string | null { - const entry = models.get(path); + /** Current buffer text, or null when no model exists for the key. */ + getValue(modelKey: string): string | null { + const entry = models.get(modelKey); if (!entry || entry.model.isDisposed()) return null; return entry.model.getValue(); }, - has(path: string): boolean { - const entry = models.get(path); + has(modelKey: string): boolean { + const entry = models.get(modelKey); return Boolean(entry && !entry.model.isDisposed()); }, - dispose(path: string): void { - const entry = models.get(path); + dispose(modelKey: string): void { + const entry = models.get(modelKey); if (!entry) return; - models.delete(path); + models.delete(modelKey); safeDispose(entry.model); }, diff --git a/apps/desktop/src/renderer/components/files/treeHelpers.test.ts b/apps/desktop/src/renderer/components/files/treeHelpers.test.ts index 6a2e55a1b..4dc800094 100644 --- a/apps/desktop/src/renderer/components/files/treeHelpers.test.ts +++ b/apps/desktop/src/renderer/components/files/treeHelpers.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { hasAncestorDirectoryPath, hasLoadedDirectoryChildren, + filesProjectSessionKey, + filesSessionKey, isUnavailableGitDecorationsError, loadedDirectoryChildrenCount, nearestLoadedAncestorDirectoryPath, @@ -31,6 +33,13 @@ describe("isUnavailableGitDecorationsError", () => { }); }); +describe("files session keys", () => { + it("keeps the project session key outside the lane-key namespace", () => { + expect(filesProjectSessionKey("/repo")).not.toBe(filesSessionKey("/repo", "__project__")); + expect(filesProjectSessionKey("/repo")).not.toBe(filesSessionKey("/repo", null)); + }); +}); + describe("file tree change refresh helpers", () => { it("resolves changed paths to the directory that needs a scoped reload", () => { expect(parentPathForFileChange("README.md")).toBe(""); diff --git a/apps/desktop/src/renderer/components/files/treeHelpers.ts b/apps/desktop/src/renderer/components/files/treeHelpers.ts index c398f6030..7fbc92ea5 100644 --- a/apps/desktop/src/renderer/components/files/treeHelpers.ts +++ b/apps/desktop/src/renderer/components/files/treeHelpers.ts @@ -5,11 +5,16 @@ import type { FilesWorkspace, } from "../../../shared/types"; -/** Per-(project, lane) session key — the unit of tab/layout restore. */ +/** Per-(project, lane) session key — legacy; used only for migration. */ export function filesSessionKey(projectRoot: string, laneId: string | null): string { return `${projectRoot}::${laneId ?? "__primary__"}`; } +/** Project-level session key — unified tab store across all lanes. */ +export function filesProjectSessionKey(projectRoot: string): string { + return JSON.stringify({ kind: "files-project-session", projectRoot }); +} + /** * Resolve which workspace a lane should show: the lane's own worktree first, * then any workspace bound to that lane, then the first available, then any. diff --git a/apps/desktop/src/renderer/components/files/v2/EditorGroup.test.tsx b/apps/desktop/src/renderer/components/files/v2/EditorGroup.test.tsx index 27648890a..a86cc5f55 100644 --- a/apps/desktop/src/renderer/components/files/v2/EditorGroup.test.tsx +++ b/apps/desktop/src/renderer/components/files/v2/EditorGroup.test.tsx @@ -4,6 +4,7 @@ import React from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MonacoModelRegistry } from "../monacoModelRegistry"; +import { editorTabId } from "./editorGroupsStore"; import { EditorGroup, type EditorGroupProps } from "./EditorGroup"; vi.mock("./ViewerHost", () => { @@ -25,13 +26,18 @@ const registry = { markSaved, } as unknown as MonacoModelRegistry; +const tabId = editorTabId("workspace-1", "src/file.ts"); + const baseProps: EditorGroupProps = { group: { id: "group-1", - activeTabId: "src/file.ts", - recentTabIds: ["src/file.ts"], + activeTabId: tabId, + recentTabIds: [tabId], tabs: [ { + id: tabId, + workspaceId: "workspace-1", + laneId: "lane-1", path: "src/file.ts", title: "file.ts", viewerKind: "code", @@ -42,15 +48,21 @@ const baseProps: EditorGroupProps = { ], }, isActiveGroup: true, - workspaceId: "workspace-1", - rootPath: "/repo", - laneId: null, - canEdit: true, - canRevealInFinder: true, + explorerWorkspaceId: "workspace-1", + explorerLaneId: "lane-1", + lanes: [{ id: "lane-1", color: "#ff0000" } as never], + tabScope: "all", + resolveTabContext: () => ({ + workspaceId: "workspace-1", + rootPath: "/repo", + laneId: "lane-1", + canEdit: true, + canRevealInFinder: true, + }), theme: "dark", registry, - dirtyPaths: new Set(["src/file.ts"]), - reloadTokensByPath: {}, + dirtyTabIds: new Set([tabId]), + reloadTokensByTabId: {}, onActivateTab: vi.fn(), onCloseTab: vi.fn(), onCloseOthers: vi.fn(), @@ -87,29 +99,19 @@ beforeEach(() => { afterEach(() => { cleanup(); - vi.clearAllMocks(); }); -describe("EditorGroup save shortcut", () => { - it("saves when Cmd/Ctrl+S originates inside the active group", () => { +describe("EditorGroup", () => { + it("renders the active tab and viewer", () => { render(); - - fireEvent.keyDown(screen.getByTestId("viewer-button"), { key: "s", metaKey: true }); - - expect(writeText).toHaveBeenCalledWith({ workspaceId: "workspace-1", path: "src/file.ts", text: "saved text" }); + expect(screen.getByRole("tab", { name: /file\.ts/i })).toBeTruthy(); + expect(screen.getByTestId("viewer-button")).toBeTruthy(); }); - it("ignores Cmd/Ctrl+S from unrelated or text-input focus targets", () => { - render( - <> - - - , - ); - - fireEvent.keyDown(screen.getByTestId("outside-input"), { key: "s", metaKey: true }); - fireEvent.keyDown(screen.getByTestId("viewer-input"), { key: "s", metaKey: true }); - + it("does not steal Cmd+S from focused text inputs", () => { + render(); + const input = screen.getByTestId("viewer-input"); + fireEvent.keyDown(input, { key: "s", metaKey: true }); expect(writeText).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx b/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx index af46f6fe7..c019748eb 100644 --- a/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx +++ b/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx @@ -1,10 +1,15 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowSquareOut, CaretRight, Copy, FloppyDisk, PushPin, SplitHorizontal, X, XCircle } from "@phosphor-icons/react"; +import type { LaneSummary } from "../../../../shared/types"; +import { getLaneAccent } from "../../lanes/laneColorPalette"; import { COLORS } from "../../lanes/laneDesignTokens"; import { getFileIcon } from "../filePresentation"; import type { MonacoModelRegistry } from "../monacoModelRegistry"; import type { EditorGroup as EditorGroupModel, EditorTab } from "./editorGroupsStore"; +import type { TabWorkspaceContext } from "./EditorGroups"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; +import type { FilesTabScope } from "./filesTabScope"; +import { filterTabsForScope, isLaneGroupBoundary, orderTabsByLane } from "./tabDisplayOrder"; import { ViewerHost } from "./ViewerHost"; import { DiffViewer } from "./viewers/DiffViewer"; import type { EditorApi, EditorThemeMode } from "./viewers/types"; @@ -13,113 +18,132 @@ import { joinDisplayPath } from "./pathDisplay"; export type EditorGroupProps = { group: EditorGroupModel; isActiveGroup: boolean; - workspaceId: string; - rootPath: string; - /** Lane id of the active workspace, or null for the primary checkout (no diff). */ - laneId: string | null; - canEdit: boolean; - canRevealInFinder: boolean; + explorerWorkspaceId: string; + explorerLaneId: string | null; + lanes: LaneSummary[]; + tabScope: FilesTabScope; + resolveTabContext: (tab: EditorTab) => TabWorkspaceContext; theme: EditorThemeMode; registry: MonacoModelRegistry; - dirtyPaths: ReadonlySet; - reloadTokensByPath: Readonly>; - onActivateTab: (groupId: string, path: string) => void; - onCloseTab: (groupId: string, path: string) => void; - onCloseOthers: (groupId: string, path: string) => void; - onPinTab: (groupId: string, path: string) => void; - onSplitTab: (groupId: string, path: string) => void; - onPromoteTab: (groupId: string, path: string) => void; + dirtyTabIds: ReadonlySet; + reloadTokensByTabId: Readonly>; + onActivateTab: (groupId: string, tabId: string) => void; + onCloseTab: (groupId: string, tabId: string) => void; + onCloseOthers: (groupId: string, tabId: string) => void; + onPinTab: (groupId: string, tabId: string) => void; + onSplitTab: (groupId: string, tabId: string) => void; + onPromoteTab: (groupId: string, tabId: string) => void; onFocusGroup: (groupId: string) => void; onSplit: (groupId: string) => void; - onDirtyChange: (path: string, dirty: boolean) => void; + onDirtyChange: (tabId: string, dirty: boolean) => void; onError: (message: string) => void; - onTabDragStart: (groupId: string, path: string) => void; + onTabDragStart: (groupId: string, tabId: string) => void; onTabDragEnd: () => void; onTabDrop: (groupId: string) => void; - /** True while any tab is being dragged — shows this group's split/move drop zones. */ isTabDragging: boolean; onBodyDrop: (targetGroupId: string, side: "left" | "right" | "center") => void; }; type DropZone = "left" | "right" | "center"; +function laneAccentForTab(tab: EditorTab, lanes: readonly LaneSummary[]): string { + const lane = tab.laneId ? lanes.find((entry) => entry.id === tab.laneId) : null; + const fallbackIndex = tab.laneId ? lanes.findIndex((entry) => entry.id === tab.laneId) : 0; + return getLaneAccent(lane, fallbackIndex >= 0 ? fallbackIndex : 0); +} + function isTextInputTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; return Boolean(target.closest("input, textarea, select, [contenteditable='true'], [role='textbox']")); } export function EditorGroup(props: EditorGroupProps) { - const { group, dirtyPaths } = props; - const { canEdit, onDirtyChange, onError, registry, workspaceId } = props; + const { group, dirtyTabIds, onDirtyChange, onError, registry, resolveTabContext } = props; const groupRef = useRef(null); const editorApis = useRef>(new Map()); const [dropZone, setDropZone] = useState(null); - const [tabMenu, setTabMenu] = useState<{ x: number; y: number; path: string } | null>(null); - const [diffPaths, setDiffPaths] = useState>(new Set()); - const activeTab = group.tabs.find((t) => t.path === group.activeTabId) ?? null; - const diffAvailable = !!props.laneId && activeTab?.viewerKind === "code"; - const activeInDiff = !!activeTab && diffAvailable && diffPaths.has(activeTab.path); - const toggleDiff = (path: string) => - setDiffPaths((prev) => { + const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null); + const [diffTabIds, setDiffTabIds] = useState>(new Set()); + + const displayTabs = useMemo(() => { + const filtered = filterTabsForScope(group.tabs, props.tabScope, props.explorerLaneId, props.explorerWorkspaceId); + return props.tabScope === "all" ? orderTabsByLane(filtered, props.lanes) : filtered; + }, [group.tabs, props.explorerLaneId, props.explorerWorkspaceId, props.lanes, props.tabScope]); + + const activeTab = useMemo(() => { + const fromGroup = group.tabs.find((t) => t.id === group.activeTabId) ?? null; + if (props.tabScope === "all") return fromGroup; + if (!fromGroup) return null; + if (displayTabs.some((tab) => tab.id === fromGroup.id)) return fromGroup; + return displayTabs[displayTabs.length - 1] ?? null; + }, [displayTabs, group.activeTabId, group.tabs, props.tabScope]); + const activeContext = activeTab ? resolveTabContext(activeTab) : null; + const diffAvailable = !!activeContext?.laneId && activeTab?.viewerKind === "code"; + const activeInDiff = !!activeTab && diffAvailable && diffTabIds.has(activeTab.id); + const toggleDiff = (tabId: string) => + setDiffTabIds((prev) => { const next = new Set(prev); - if (next.has(path)) next.delete(path); - else next.add(path); + if (next.has(tabId)) next.delete(tabId); + else next.add(tabId); return next; }); - const tabMenuItems = (path: string): ContextMenuItem[] => { - const pinned = group.tabs.find((t) => t.path === path)?.pinned ?? false; - const name = path.split("/").filter(Boolean).pop() ?? path; + const tabMenuItems = (tabId: string): ContextMenuItem[] => { + const tab = group.tabs.find((t) => t.id === tabId); + if (!tab) return []; + const ctx = resolveTabContext(tab); + const pinned = tab.pinned; + const name = tab.path.split("/").filter(Boolean).pop() ?? tab.path; return [ - { type: "item", label: "Close", icon: , onClick: () => props.onCloseTab(group.id, path) }, - { type: "item", label: "Close Others", icon: , onClick: () => props.onCloseOthers(group.id, path), disabled: group.tabs.length <= 1 }, + { type: "item", label: "Close", icon: , onClick: () => props.onCloseTab(group.id, tabId) }, + { type: "item", label: "Close Others", icon: , onClick: () => props.onCloseOthers(group.id, tabId), disabled: group.tabs.length <= 1 }, { type: "separator" }, - { type: "item", label: pinned ? "Pinned" : "Pin", icon: , onClick: () => props.onPinTab(group.id, path), disabled: pinned }, - { type: "item", label: "Split Right", icon: , onClick: () => props.onSplitTab(group.id, path), disabled: group.tabs.length <= 1 }, + { type: "item", label: pinned ? "Pinned" : "Pin", icon: , onClick: () => props.onPinTab(group.id, tabId), disabled: pinned }, + { type: "item", label: "Split Right", icon: , onClick: () => props.onSplitTab(group.id, tabId), disabled: group.tabs.length <= 1 }, { type: "separator" }, - { type: "item", label: "Copy Full Path", icon: , onClick: () => void window.ade.app.writeClipboardText?.(joinDisplayPath(props.rootPath, path)) }, - { type: "item", label: "Copy Relative Path", icon: , onClick: () => void window.ade.app.writeClipboardText?.(path) }, + { type: "item", label: "Copy Full Path", icon: , onClick: () => void window.ade.app.writeClipboardText?.(joinDisplayPath(ctx.rootPath, tab.path)) }, + { type: "item", label: "Copy Relative Path", icon: , onClick: () => void window.ade.app.writeClipboardText?.(tab.path) }, { type: "item", label: "Copy Name", icon: , onClick: () => void window.ade.app.writeClipboardText?.(name) }, { type: "item", label: "Reveal in Finder", icon: , - onClick: () => void window.ade.app.openPathInEditor?.({ rootPath: props.rootPath, relativePath: path, target: "finder" }).catch(() => {}), - disabled: !props.canRevealInFinder, + onClick: () => void window.ade.app.openPathInEditor?.({ rootPath: ctx.rootPath, relativePath: tab.path, target: "finder" }).catch(() => {}), + disabled: !ctx.canRevealInFinder, }, ]; }; - const registerApi = (path: string, api: EditorApi | null) => { - if (api) editorApis.current.set(path, api); - else editorApis.current.delete(path); + const registerApi = (tabId: string, api: EditorApi | null) => { + if (api) editorApis.current.set(tabId, api); + else editorApis.current.delete(tabId); }; const saveActive = useCallback(() => { - if (!activeTab) return; - const api = editorApis.current.get(activeTab.path); + if (!activeTab || !activeContext) return; + const api = editorApis.current.get(activeTab.id); if (api) { void api.save().catch((err) => { onError(err instanceof Error ? err.message : String(err)); }); return; } - if (!canEdit || activeTab.viewerKind !== "code") return; - const text = registry.getValue(activeTab.path); + if (!activeContext.canEdit || activeTab.viewerKind !== "code") return; + const text = registry.getValue(activeTab.id); if (text == null) return; void window.ade.files - .writeText({ workspaceId, path: activeTab.path, text }) + .writeText({ workspaceId: activeContext.workspaceId, path: activeTab.path, text }) .then(() => { - registry.markSaved(activeTab.path); - onDirtyChange(activeTab.path, false); + registry.markSaved(activeTab.id); + onDirtyChange(activeTab.id, false); }) .catch((err) => { onError(err instanceof Error ? err.message : String(err)); }); - }, [activeTab, canEdit, onDirtyChange, onError, registry, workspaceId]); + }, [activeContext, activeTab, onDirtyChange, onError, registry]); useEffect(() => { - if (!props.isActiveGroup || !activeTab || !canEdit || activeTab.viewerKind !== "code") return; + if (!props.isActiveGroup || !activeTab || !activeContext?.canEdit || activeTab.viewerKind !== "code") return; const onKey = (event: KeyboardEvent) => { const mod = event.metaKey || event.ctrlKey; if (!mod || event.shiftKey || event.altKey || (event.key !== "s" && event.key !== "S")) return; @@ -132,7 +156,7 @@ export function EditorGroup(props: EditorGroupProps) { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [activeTab, canEdit, props.isActiveGroup, saveActive]); + }, [activeContext?.canEdit, activeTab, props.isActiveGroup, saveActive]); return (
props.onFocusGroup(group.id)} style={{ outline: props.isActiveGroup ? `1px solid ${COLORS.accentBorder}` : "none", outlineOffset: -1 }} > - {/* Tab strip */}
e.preventDefault()} onDrop={() => props.onTabDrop(group.id)} > - {group.tabs.length === 0 ? ( + {displayTabs.length === 0 ? (
No open files
) : ( - group.tabs.map((tab) => ( + displayTabs.map((tab, index) => ( props.onActivateTab(group.id, tab.path)} - onClose={() => props.onCloseTab(group.id, tab.path)} - onPromote={() => props.onPromoteTab(group.id, tab.path)} - onDragStart={() => props.onTabDragStart(group.id, tab.path)} + active={tab.id === group.activeTabId} + dirty={dirtyTabIds.has(tab.id)} + laneAccent={props.tabScope === "all" ? laneAccentForTab(tab, props.lanes) : undefined} + showLaneDivider={props.tabScope === "all" && isLaneGroupBoundary(displayTabs, index)} + onActivate={() => props.onActivateTab(group.id, tab.id)} + onClose={() => props.onCloseTab(group.id, tab.id)} + onPromote={() => props.onPromoteTab(group.id, tab.id)} + onDragStart={() => props.onTabDragStart(group.id, tab.id)} onDragEnd={props.onTabDragEnd} - onContextMenu={(x, y) => setTabMenu({ x, y, path: tab.path })} + onContextMenu={(x, y) => setTabMenu({ x, y, tabId: tab.id })} /> )) )}
- {/* Breadcrumb + toolbar */} - {activeTab ? ( + {activeTab && activeContext ? (
@@ -181,7 +205,7 @@ export function EditorGroup(props: EditorGroupProps) {
) : null} - {activeTab.viewerKind === "code" && props.canEdit ? ( + {activeTab.viewerKind === "code" && activeContext.canEdit ? ( @@ -203,21 +227,20 @@ export function EditorGroup(props: EditorGroupProps) {
) : null} - {/* Body */}
- {activeTab && activeInDiff && props.laneId ? ( - - ) : activeTab ? ( + {activeTab && activeContext && activeInDiff && activeContext.laneId ? ( + + ) : activeTab && activeContext ? ( props.onPromoteTab(group.id, path)} + onEdit={(tabId) => props.onPromoteTab(group.id, tabId)} onRegisterEditorApi={registerApi} onError={props.onError} /> @@ -227,7 +250,6 @@ export function EditorGroup(props: EditorGroupProps) {
)} - {/* Drag-to-split / move drop zone (only while a tab is being dragged). */} {props.isTabDragging ? (
{tabMenu ? ( - setTabMenu(null)} /> + setTabMenu(null)} /> ) : null}
); @@ -275,6 +297,8 @@ function TabButton({ tab, active, dirty, + laneAccent, + showLaneDivider, onActivate, onClose, onPromote, @@ -285,6 +309,8 @@ function TabButton({ tab: EditorTab; active: boolean; dirty: boolean; + laneAccent?: string; + showLaneDivider?: boolean; onActivate: () => void; onClose: () => void; onPromote: () => void; @@ -299,8 +325,7 @@ function TabButton({ aria-selected={active} draggable onDragStart={(e) => { - // Some Chromium builds won't start a DnD without payload set. - try { e.dataTransfer.setData("text/plain", tab.path); e.dataTransfer.effectAllowed = "move"; } catch { /* ignore */ } + try { e.dataTransfer.setData("text/plain", tab.id); e.dataTransfer.effectAllowed = "move"; } catch { /* ignore */ } onDragStart(); }} onDragEnd={onDragEnd} @@ -319,6 +344,8 @@ function TabButton({ className="group flex shrink-0 cursor-pointer items-center gap-1.5 border-r px-2.5 py-1.5 text-xs" style={{ borderColor: COLORS.border, + borderLeft: laneAccent ? `2px solid ${laneAccent}` : showLaneDivider ? `2px solid ${COLORS.border}` : undefined, + marginLeft: showLaneDivider ? 4 : undefined, background: active ? COLORS.cardBg : "transparent", color: active ? COLORS.textPrimary : COLORS.textMuted, fontStyle: tab.preview ? "italic" : "normal", diff --git a/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx b/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx index 1543eba54..387e0adeb 100644 --- a/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx +++ b/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx @@ -1,58 +1,58 @@ import React, { Fragment } from "react"; +import { Funnel, Stack } from "@phosphor-icons/react"; import { Group, Panel } from "react-resizable-panels"; +import type { FilesWorkspace, LaneSummary } from "../../../../shared/types"; import { ResizeGutter } from "../../ui/ResizeGutter"; import type { MonacoModelRegistry } from "../monacoModelRegistry"; -import type { GroupsState } from "./editorGroupsStore"; +import type { EditorTab, GroupsState } from "./editorGroupsStore"; import { EditorGroup } from "./EditorGroup"; +import type { FilesTabScope } from "./filesTabScope"; import type { EditorThemeMode } from "./viewers/types"; -// Persisted divider sizes, keyed by lane session + exact group layout. A given -// split config (e.g. [g1,g2]) restores its dragged sizes when you return to it; -// a NEW split config has no entry and starts even. In-memory (per app run). const splitSizesByKey = new Map>(); -export type EditorGroupsProps = { - sessionKey: string; - state: GroupsState; +export type TabWorkspaceContext = { workspaceId: string; rootPath: string; laneId: string | null; canEdit: boolean; canRevealInFinder: boolean; +}; + +export type EditorGroupsProps = { + sessionKey: string; + state: GroupsState; + workspaces: FilesWorkspace[]; + explorerWorkspaceId: string; + explorerLaneId: string | null; + lanes: LaneSummary[]; + tabScope: FilesTabScope; + onTabScopeChange: (scope: FilesTabScope) => void; + resolveTabContext: (tab: EditorTab) => TabWorkspaceContext; theme: EditorThemeMode; registry: MonacoModelRegistry; - dirtyPaths: ReadonlySet; - reloadTokensByPath: Readonly>; - onActivateTab: (groupId: string, path: string) => void; - onCloseTab: (groupId: string, path: string) => void; - onCloseOthers: (groupId: string, path: string) => void; - onPinTab: (groupId: string, path: string) => void; - onSplitTab: (groupId: string, path: string) => void; - onPromoteTab: (groupId: string, path: string) => void; + dirtyTabIds: ReadonlySet; + reloadTokensByTabId: Readonly>; + onActivateTab: (groupId: string, tabId: string) => void; + onCloseTab: (groupId: string, tabId: string) => void; + onCloseOthers: (groupId: string, tabId: string) => void; + onPinTab: (groupId: string, tabId: string) => void; + onSplitTab: (groupId: string, tabId: string) => void; + onPromoteTab: (groupId: string, tabId: string) => void; onFocusGroup: (groupId: string) => void; onSplit: (groupId: string) => void; - onDirtyChange: (path: string, dirty: boolean) => void; + onDirtyChange: (tabId: string, dirty: boolean) => void; onError: (message: string) => void; - onTabDragStart: (groupId: string, path: string) => void; + onTabDragStart: (groupId: string, tabId: string) => void; onTabDragEnd: () => void; onTabDrop: (groupId: string) => void; isTabDragging: boolean; onBodyDrop: (targetGroupId: string, side: "left" | "right" | "center") => void; }; -/** - * Editor groups laid out side-by-side in a single horizontal resizable group. - * Splits always tile left↔right (VSCode-style), unlike the perpendicular grid - * tiling of PaneTilingLayout — so an explicit split or a drag-to-edge produces - * columns, never stacked rows. react-resizable-panels reconciles the dynamic - * panel set by stable `id`. - */ export function EditorGroups(props: EditorGroupsProps) { const ids = props.state.groupOrder.filter((id) => props.state.groups[id]); const evenSize = (100 / Math.max(1, ids.length)).toFixed(4); - // Re-key only when the SET of groups changes (split/close/move) so the group - // re-initialises; manual divider resizes keep the same key and are preserved. - // Sizes are percentages, so they reflow with the window width. const layoutKey = ids.join("|"); const sizeKey = `${props.sessionKey}::${layoutKey}`; const persisted = splitSizesByKey.get(sizeKey); @@ -62,50 +62,74 @@ export function EditorGroups(props: EditorGroupsProps) { return typeof saved === "number" && Number.isFinite(saved) ? `${saved}%` : `${evenSize}%`; }; + const scopeLabel = props.tabScope === "all" ? "All lanes" : "This lane only"; + const scopeTitle = + props.tabScope === "all" + ? "Keep files from all lanes open. Click to show only this lane's files." + : "Show only this lane's files. Click to keep files from all lanes open."; + return ( - { - if (next && Object.keys(next).length > 1) splitSizesByKey.set(sizeKey, next); - }} - > - {ids.map((id, i) => ( - - - - - {i < ids.length - 1 ? : null} - - ))} - +
+
+ +
+ { + if (next && Object.keys(next).length > 1) splitSizesByKey.set(sizeKey, next); + }} + > + {ids.map((id, i) => ( + + + + + {i < ids.length - 1 ? : null} + + ))} + +
); } diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx new file mode 100644 index 000000000..65be7b886 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FilesWorkspace } from "../../../../shared/types"; +import { useEditorGroupsStore } from "./editorGroupsStore"; +import { FilesWorkbench } from "./FilesWorkbench"; + +const testState = vi.hoisted(() => ({ + appState: { + project: { rootPath: "/repo" }, + projectBinding: { kind: "local" }, + selectedLaneId: "lane-a", + lanes: [ + { id: "lane-a", color: "#ff0000" }, + { id: "lane-b", color: "#00ff00" }, + ], + }, +})); + +vi.mock("../../../state/appStore", () => ({ + useAppStore: (selector: (state: typeof testState.appState) => unknown) => selector(testState.appState), +})); + +vi.mock("../FilesExplorer", () => ({ + FilesExplorer: ({ onOpenFile }: { onOpenFile: (path: string) => void }) => ( + + ), +})); + +vi.mock("./WorkspacePicker", () => ({ + WorkspacePicker: ({ onChange }: { onChange: (workspaceId: string) => void }) => ( + + ), +})); + +vi.mock("./EditorGroups", () => ({ + EditorGroups: (props: { + state: { groups: Record }> }; + dirtyTabIds: ReadonlySet; + resolveTabContext: (tab: { id: string }) => { canEdit: boolean }; + onDirtyChange: (tabId: string, dirty: boolean) => void; + }) => { + const tab = Object.values(props.state.groups).flatMap((group) => group.tabs)[0]; + return ( +
+
{tab ? 1 : 0}
+
{tab ? String(props.resolveTabContext(tab).canEdit) : "unknown"}
+
{props.dirtyTabIds.size}
+ +
+ ); + }, +})); + +const workspaces: FilesWorkspace[] = [ + { + id: "workspace-a", + kind: "worktree", + name: "Lane A", + rootPath: "/repo/.ade/worktrees/a", + laneId: "lane-a", + isReadOnlyByDefault: false, + mobileReadOnly: true, + }, + { + id: "workspace-b", + kind: "worktree", + name: "Lane B", + rootPath: "/repo/.ade/worktrees/b", + laneId: "lane-b", + isReadOnlyByDefault: false, + mobileReadOnly: true, + }, +]; + +describe("FilesWorkbench", () => { + beforeEach(() => { + useEditorGroupsStore.setState({ sessions: {} }); + vi.spyOn(window, "confirm").mockReturnValue(true); + Object.defineProperty(window, "ade", { + configurable: true, + value: { + app: { + writeClipboardText: vi.fn(), + openPathInEditor: vi.fn(), + }, + project: { + getDroppedPath: vi.fn(), + }, + files: { + listWorkspaces: vi.fn(async () => workspaces), + listTree: vi.fn(async () => []), + refreshGitDecorations: vi.fn(async () => null), + readFile: vi.fn(async () => ({ + path: "src/a.ts", + content: "const a = 1;\n", + encoding: "utf8", + languageId: "typescript", + isBinary: false, + })), + watchChanges: vi.fn(async () => undefined), + stopWatching: vi.fn(async () => undefined), + onChange: vi.fn(() => () => undefined), + }, + }, + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + useEditorGroupsStore.setState({ sessions: {} }); + localStorage.clear(); + }); + + it("preserves dirty open tabs when switching explorer workspaces", async () => { + render(); + + fireEvent.click(await screen.findByTestId("open-file")); + await waitFor(() => expect(screen.getByTestId("tab-count").textContent).toBe("1")); + expect(screen.getByTestId("can-edit").textContent).toBe("true"); + expect(window.ade.files.readFile).toHaveBeenCalledWith({ workspaceId: "workspace-a", path: "src/a.ts" }); + + fireEvent.click(screen.getByTestId("mark-dirty")); + await waitFor(() => expect(screen.getByTestId("dirty-count").textContent).toBe("1")); + + fireEvent.click(screen.getByTestId("switch-workspace")); + + expect(window.confirm).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId("dirty-count").textContent).toBe("1")); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index 6e8ff5154..d25c6b970 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowSquareOut, Copy, FilePlus, FolderPlus, LockOpen, PencilSimple, Trash } from "@phosphor-icons/react"; +import { ArrowSquareOut, Copy, FilePlus, FolderPlus, PencilSimple, Trash } from "@phosphor-icons/react"; import type { FileTreeNode, FilesWorkspace } from "../../../../shared/types"; import { useAppStore } from "../../../state/appStore"; import { createMonacoModelRegistry } from "../monacoModelRegistry"; @@ -11,6 +11,7 @@ import { applyGitStatusToTree, appendTreeNodeChildren, defaultFilesWorkspaceId, + filesProjectSessionKey, filesSessionKey, formatFilesError, hasAncestorDirectoryPath, @@ -29,14 +30,19 @@ import { closeOtherTabs, closeTab, createInitialGroupsState, + editorTabId, + isTabOpenInGroups, + mergeLegacyLaneSessions, moveTabToGroup, openInGroup, pinTab, promoteFromPreview, splitGroup, splitTabToNewGroup, + upgradeLegacySession, useEditorGroupsStore, } from "./editorGroupsStore"; +import { getFilesTabScope, toggleFilesTabScope, type FilesTabScope } from "./filesTabScope"; import { resolveViewerKind } from "./viewerRegistry"; import { invalidateFileContent, primeFileContent } from "./useFileContent"; import { forgetRecentFilesUnder, getRecentFiles, isNestedFilePath, pruneMissingRootRecentFiles, recordRecentFile } from "./recentFiles"; @@ -63,6 +69,10 @@ const rootTreeCacheByKey = new Map(); const readCachedWorkspaces = (projectRoot: string): FilesWorkspace[] => workspacesCacheByProject.get(projectRoot) ?? []; const rootTreeCacheKey = (projectRoot: string, workspaceId: string): string => `${projectRoot}::${workspaceId}`; +function canEditWorkspace(workspace: FilesWorkspace | null | undefined): boolean { + return workspace != null && !workspace.isReadOnlyByDefault; +} + function mergeExternalWorkspaces(next: FilesWorkspace[], previous: FilesWorkspace[]): FilesWorkspace[] { const seen = new Set(next.map((workspace) => workspace.id)); const preserved = previous.filter((workspace) => workspace.kind === "external" && !seen.has(workspace.id)); @@ -104,6 +114,7 @@ export function FilesWorkbench({ const projectRootPath = project?.rootPath ?? ""; const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const selectedLaneId = useAppStore((s) => s.selectedLaneId); + const lanes = useAppStore((s) => s.lanes); const globalLaneId = preferredLaneId ?? selectedLaneId ?? null; // Seed from the cross-mount cache so a repeat visit renders immediately. @@ -111,34 +122,16 @@ export function FilesWorkbench({ const initialWorkspaceId = defaultFilesWorkspaceId(cachedWorkspaces, globalLaneId); const [workspaces, setWorkspaces] = useState(cachedWorkspaces); const [workspacesLoaded, setWorkspacesLoaded] = useState(cachedWorkspaces.length > 0); + const [workspacesListedProjectRoot, setWorkspacesListedProjectRoot] = useState(null); const [workspaceId, setWorkspaceId] = useState(initialWorkspaceId); const workspace = useMemo(() => workspaces.find((w) => w.id === workspaceId) ?? null, [workspaces, workspaceId]); const rootPath = workspace?.rootPath ?? projectRootPath; - // Per-workspace, session-only "edit anyway" override. The primary lane is - // read-only-by-default (edit-protected), and there was no way to edit its files - // from the Files tab; this lets you opt in for the session without permanently - // flipping the lane's protection. - const [editOverrides, setEditOverrides] = useState>(() => new Set()); - const editOverride = workspaceId ? editOverrides.has(workspaceId) : false; - const canEdit = workspace ? (!workspace.isReadOnlyByDefault || editOverride) : false; + const canEdit = canEditWorkspace(workspace); const canRevealInFinder = workspace != null && (workspace.kind === "external" || !isRemoteProject); - const showEnableEditing = Boolean(workspace?.isReadOnlyByDefault) && !editOverride; - const enableEditingForWorkspace = () => { - if (!workspaceId) return; - setEditOverrides((prev) => { - const next = new Set(prev); - next.add(workspaceId); - return next; - }); - }; const branch = workspace?.branchRef?.replace("refs/heads/", "") ?? null; const theme: EditorThemeMode = "dark"; - // Session (tabs/layout) follows the ACTIVE workspace's lane, so switching the - // workspace picker switches the tab set; falls back to the global lane until resolved. - const sessionKey = filesSessionKey( - projectRootPath, - workspace?.kind === "external" ? workspace.id : workspace?.laneId ?? globalLaneId, - ); + const sessionKey = filesProjectSessionKey(projectRootPath); + const [tabScope, setTabScope] = useState(() => getFilesTabScope(projectRootPath)); const [tree, setTree] = useState( () => rootTreeCacheByKey.get(rootTreeCacheKey(projectRootPath, initialWorkspaceId)) ?? [], @@ -147,9 +140,9 @@ export function FilesWorkbench({ const [loadingDirs, setLoadingDirs] = useState>(new Set()); const [selectedNodePath, setSelectedNodePath] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - const [dirtyPaths, setDirtyPaths] = useState>(new Set()); + const [dirtyTabIds, setDirtyTabIds] = useState>(new Set()); const [dirtyBufferRevision, setDirtyBufferRevision] = useState(0); - const [reloadTokensByPath, setReloadTokensByPath] = useState>({}); + const [reloadTokensByTabId, setReloadTokensByTabId] = useState>({}); const [error, setError] = useState(null); const [overlay, setOverlay] = useState< | null @@ -171,15 +164,15 @@ export function FilesWorkbench({ const workspacesProjectRootRef = useRef(projectRootPath); const renameNonceRef = useRef(0); const registryRef = useRef(createMonacoModelRegistry()); - const dragRef = useRef<{ groupId: string; path: string } | null>(null); + const dragRef = useRef<{ groupId: string; tabId: string } | null>(null); const workspaceIdRef = useRef(workspaceId); workspaceIdRef.current = workspaceId; const treeRef = useRef(tree); treeRef.current = tree; const rootPathRef = useRef(rootPath); rootPathRef.current = rootPath; - const dirtyPathsRef = useRef(dirtyPaths); - dirtyPathsRef.current = dirtyPaths; + const dirtyTabIdsRef = useRef(dirtyTabIds); + dirtyTabIdsRef.current = dirtyTabIds; const store = useEditorGroupsStore(); const groupsState = store.sessions[sessionKey] ?? createInitialGroupsState(); @@ -202,17 +195,76 @@ export function FilesWorkbench({ ); const activeGroup = groupsState.groups[groupsState.activeGroupId]; - const activeTab = activeGroup?.tabs.find((t) => t.path === activeGroup.activeTabId) ?? null; - const openCount = useMemo( - () => new Set(Object.values(groupsState.groups).flatMap((g) => g.tabs.map((t) => t.path))).size, + const activeTab = activeGroup?.tabs.find((t) => t.id === activeGroup.activeTabId) ?? null; + const allOpenTabs = useMemo( + () => Object.values(groupsState.groups).flatMap((g) => g.tabs), [groupsState.groups], ); - const openTabPaths = useMemo( - () => new Set(Object.values(groupsState.groups).flatMap((g) => g.tabs.map((t) => t.path))), - [groupsState.groups], + const openCount = allOpenTabs.length; + const openWorkspaceIds = useMemo(() => new Set(allOpenTabs.map((t) => t.workspaceId)), [allOpenTabs]); + const resolveTabContext = useCallback( + (tab: EditorTab) => { + const ws = workspaces.find((candidate) => candidate.id === tab.workspaceId); + const wsRoot = ws?.rootPath ?? projectRootPath; + return { + workspaceId: tab.workspaceId, + rootPath: wsRoot, + laneId: tab.laneId, + canEdit: canEditWorkspace(ws), + canRevealInFinder: ws != null && (ws.kind === "external" || !isRemoteProject), + }; + }, + [isRemoteProject, projectRootPath, workspaces], ); - const openTabPathsRef = useRef(openTabPaths); - openTabPathsRef.current = openTabPaths; + + const migratedSessionsRef = useRef(null); + useEffect(() => { + if (!projectRootPath || workspaces.length === 0 || workspacesListedProjectRoot !== projectRootPath) return; + if (migratedSessionsRef.current === projectRootPath) return; + migratedSessionsRef.current = projectRootPath; + const projectKey = filesProjectSessionKey(projectRootPath); + const existing = store.getSession(projectKey); + const hasProjectTabs = existing && Object.values(existing.groups).some((g) => g.tabs.length > 0); + if (hasProjectTabs) return; + + const legacySessions: ReturnType[] = []; + for (const ws of workspaces) { + if (ws.kind === "external") continue; + const laneKey = filesSessionKey(projectRootPath, ws.laneId); + const legacy = store.getSession(laneKey); + if (legacy && Object.values(legacy.groups).some((g) => g.tabs.length > 0)) { + legacySessions.push(upgradeLegacySession(legacy, ws.id, ws.laneId)); + } + } + if (legacySessions.length > 0) { + store.apply(projectKey, () => mergeLegacyLaneSessions(legacySessions)); + } + }, [projectRootPath, store, workspaces, workspacesListedProjectRoot]); + + const allOpenTabsRef = useRef(allOpenTabs); + allOpenTabsRef.current = allOpenTabs; + + useEffect(() => { + if (tabScope !== "lane") return; + const explorerLane = workspace?.laneId ?? null; + const inCurrentScope = (tab: EditorTab): boolean => + explorerLane != null ? tab.laneId === explorerLane : tab.workspaceId === workspaceId; + for (const groupId of groupsState.groupOrder) { + const group = groupsState.groups[groupId]; + if (!group?.activeTabId) continue; + const active = group.tabs.find((tab) => tab.id === group.activeTabId); + if (!active || inCurrentScope(active)) continue; + const fallback = group.tabs.find(inCurrentScope); + if (fallback) { + applyGroups((s) => activateTab(s, groupId, fallback.id)); + } + } + }, [applyGroups, groupsState.groupOrder, groupsState.groups, tabScope, workspace?.laneId, workspaceId]); + + useEffect(() => { + setTabScope(getFilesTabScope(projectRootPath)); + }, [projectRootPath]); + const knownRootPaths = useMemo(() => new Set(tree.map((node) => node.path)), [tree]); const recentFiles = getRecentFiles(sessionKey); const visibleRecentFiles = useMemo( @@ -229,38 +281,43 @@ export function FilesWorkbench({ pruneMissingRootRecentFiles(sessionKey, knownRootPaths); }, [knownRootPaths, sessionKey, tree.length]); - const dirtyPathsUnder = useCallback( - (target: string): string[] => [...dirtyPaths].filter((path) => pathIsAtOrUnder(path, target)), - [dirtyPaths], + const dirtyTabsUnder = useCallback( + (wsId: string, target: string): string[] => + [...dirtyTabIds].filter((tabId) => { + const candidate = allOpenTabs.find((entry) => entry.id === tabId); + return candidate?.workspaceId === wsId && pathIsAtOrUnder(candidate.path, target); + }), + [allOpenTabs, dirtyTabIds], ); - const confirmDiscardDirtyPaths = useCallback((paths: readonly string[], action: string): boolean => { - if (paths.length === 0) return true; - const label = paths.length === 1 ? `"${paths[0]}" has` : `${paths.length} files have`; + const confirmDiscardDirtyTabIds = useCallback((tabIds: readonly string[], action: string): boolean => { + if (tabIds.length === 0) return true; + const labels = tabIds.map((tabId) => allOpenTabs.find((tab) => tab.id === tabId)?.path ?? tabId); + const label = labels.length === 1 ? `"${labels[0]}" has` : `${labels.length} files have`; return window.confirm(`${label} unsaved changes. ${action} anyway?`); - }, []); + }, [allOpenTabs]); - const pruneClosedPathState = useCallback((shouldPrune: (path: string) => boolean) => { - setDirtyPaths((prev) => { + const pruneClosedTabState = useCallback((shouldPrune: (tabId: string) => boolean) => { + setDirtyTabIds((prev) => { let changed = false; const next = new Set(); - for (const path of prev) { - if (shouldPrune(path)) { + for (const tabId of prev) { + if (shouldPrune(tabId)) { changed = true; } else { - next.add(path); + next.add(tabId); } } return changed ? next : prev; }); - setReloadTokensByPath((prev) => { + setReloadTokensByTabId((prev) => { let changed = false; const next: Record = {}; - for (const [path, token] of Object.entries(prev)) { - if (shouldPrune(path)) { + for (const [tabId, token] of Object.entries(prev)) { + if (shouldPrune(tabId)) { changed = true; } else { - next[path] = token; + next[tabId] = token; } } return changed ? next : prev; @@ -271,37 +328,42 @@ export function FilesWorkbench({ const selectWorkspace = useCallback( (nextWorkspaceId: string) => { if (!nextWorkspaceId || nextWorkspaceId === workspaceId) return; - if (!confirmDiscardDirtyPaths([...dirtyPaths], "Switch workspaces")) return; - clearDirtyBuffersForWorkspace(rootPath); - setDirtyPaths(new Set()); - setDirtyBufferRevision((revision) => revision + 1); - setReloadTokensByPath({}); setWorkspaceId(nextWorkspaceId); }, - [confirmDiscardDirtyPaths, dirtyPaths, rootPath, workspaceId], + [workspaceId], ); useEffect(() => { - if (!active || dirtyPaths.size === 0) return; + if (!active || dirtyTabIds.size === 0) return; const onBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); - }, [active, dirtyPaths.size]); + }, [active, dirtyTabIds.size]); useEffect(() => { - if (!rootPath) return; - const buffers = [...dirtyPaths].flatMap((path) => { - const content = registryRef.current.getValue(path); - return content == null ? [] : [{ path, content }]; - }); - replaceDirtyBufferValuesForWorkspace(rootPath, buffers); + const dirtyByWorkspace = new Map>(); + for (const tabId of dirtyTabIds) { + const tab = allOpenTabs.find((candidate) => candidate.id === tabId); + if (!tab) continue; + const content = registryRef.current.getValue(tabId); + if (content == null) continue; + const ctx = resolveTabContext(tab); + const list = dirtyByWorkspace.get(ctx.rootPath) ?? []; + list.push({ path: tab.path, content }); + dirtyByWorkspace.set(ctx.rootPath, list); + } + for (const [wsRoot, buffers] of dirtyByWorkspace) { + replaceDirtyBufferValuesForWorkspace(wsRoot, buffers); + } return () => { - clearDirtyBuffersForWorkspace(rootPath); + for (const wsRoot of dirtyByWorkspace.keys()) { + clearDirtyBuffersForWorkspace(wsRoot); + } }; - }, [dirtyBufferRevision, dirtyPaths, rootPath]); + }, [allOpenTabs, dirtyBufferRevision, dirtyTabIds, resolveTabContext]); /* ---- Workspace resolution ---- */ useEffect(() => { @@ -313,6 +375,7 @@ export function FilesWorkbench({ const cachedForProject = readCachedWorkspaces(projectRootPath).filter((workspace) => workspace.kind !== "external"); setWorkspaces(cachedForProject); setWorkspacesLoaded(cachedForProject.length > 0); + setWorkspacesListedProjectRoot(null); } window.ade.files .listWorkspaces() @@ -324,17 +387,20 @@ export function FilesWorkbench({ return merged; }); setWorkspacesLoaded(true); + setWorkspacesListedProjectRoot(projectRootPath); }) .catch(() => { - if (!cancelled) setWorkspacesLoaded(true); + if (!cancelled) { + setWorkspacesLoaded(true); + setWorkspacesListedProjectRoot(projectRootPath); + } }); return () => { cancelled = true; }; }, [active, projectRootPath]); - // Resolve the workspace from the global lane on mount + lane changes. - // Workspace-list refreshes preserve a still-valid manual/external selection. + // Resolve the explorer workspace from the global lane on mount + lane changes. useEffect(() => { if (!workspaces.length) return; const laneChanged = lastGlobalLaneIdRef.current !== globalLaneId; @@ -343,15 +409,9 @@ export function FilesWorkbench({ if (!laneChanged && current && workspaces.some((candidate) => candidate.id === current)) return; const next = defaultFilesWorkspaceId(workspaces, globalLaneId) || current; if (next && next !== current) { - const dirty = [...dirtyPathsRef.current]; - if (dirty.length > 0 && !confirmDiscardDirtyPaths(dirty, "Switch workspaces")) return; - clearDirtyBuffersForWorkspace(rootPathRef.current); - setDirtyPaths(new Set()); - setDirtyBufferRevision((revision) => revision + 1); - setReloadTokensByPath({}); setWorkspaceId(next); } - }, [workspaces, globalLaneId, confirmDiscardDirtyPaths]); + }, [workspaces, globalLaneId]); /* ---- Tree loading ---- */ const refreshTreeGitDecorations = useCallback( @@ -393,24 +453,24 @@ export function FilesWorkbench({ } }, [workspaceId, projectRootPath, refreshTreeGitDecorations]); - // Reset + load when the workspace changes; dispose models from the old lane. + // Reload explorer tree when the selected workspace changes (tabs stay open). useEffect(() => { if (!active || !workspaceId) return; - // Seed from cache (instant) rather than clearing to empty, then refresh. setTree(rootTreeCacheByKey.get(rootTreeCacheKey(projectRootPath, workspaceId)) ?? []); setExpanded(new Set()); setLoadingDirs(new Set()); setError(null); - setDirtyPaths(new Set()); - setDirtyBufferRevision((revision) => revision + 1); - setReloadTokensByPath({}); void refreshRoot(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, workspaceId, refreshRoot, projectRootPath]); + + useEffect(() => { + if (!active) return; const registry = registryRef.current; return () => { registry.disposeAll(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [active, workspaceId, refreshRoot, projectRootPath]); + }, [active]); const fetchDirectoryChildren = useCallback(async (reqId: string, parentPath: string, minChildren = MAX_AUTO_LOADED_CHILDREN) => { const children: FileTreeNode[] = []; @@ -609,13 +669,20 @@ export function FilesWorkbench({ }; const unsub = window.ade.files.onChange((ev) => { - if (ev.workspaceId !== workspaceIdRef.current) return; - // Drop the cached content for the changed path so a reopen re-reads disk. + const isExplorerWorkspace = ev.workspaceId === workspaceIdRef.current; + const openTab = allOpenTabsRef.current.find((tab) => tab.workspaceId === ev.workspaceId && tab.path === ev.path); + const openTabOld = ev.oldPath + ? allOpenTabsRef.current.find((tab) => tab.workspaceId === ev.workspaceId && tab.path === ev.oldPath) + : undefined; invalidateFileContent(ev.workspaceId, ev.path); if (ev.oldPath) invalidateFileContent(ev.workspaceId, ev.oldPath); - if (openTabPathsRef.current.has(ev.path) && !dirtyPathsRef.current.has(ev.path)) { - setReloadTokensByPath((prev) => ({ ...prev, [ev.path]: (prev[ev.path] ?? 0) + 1 })); + if (openTab && !dirtyTabIdsRef.current.has(openTab.id)) { + setReloadTokensByTabId((prev) => ({ ...prev, [openTab.id]: (prev[openTab.id] ?? 0) + 1 })); + } + if (openTabOld && !dirtyTabIdsRef.current.has(openTabOld.id)) { + setReloadTokensByTabId((prev) => ({ ...prev, [openTabOld.id]: (prev[openTabOld.id] ?? 0) + 1 })); } + if (!isExplorerWorkspace) return; enqueuePathRefresh(ev.path); enqueuePathRefresh(ev.oldPath); if (timer) clearTimeout(timer); @@ -623,13 +690,18 @@ export function FilesWorkbench({ flushQueuedRefreshes(); }, 200); }); - void window.ade.files.watchChanges({ workspaceId, includeIgnored: true }).catch(() => {}); + const watchedIds = new Set([workspaceId, ...openWorkspaceIds]); + for (const watchedId of watchedIds) { + void window.ade.files.watchChanges({ workspaceId: watchedId, includeIgnored: true }).catch(() => {}); + } return () => { if (timer) clearTimeout(timer); unsub(); - void window.ade.files.stopWatching({ workspaceId, includeIgnored: true }).catch(() => {}); + for (const watchedId of watchedIds) { + void window.ade.files.stopWatching({ workspaceId: watchedId, includeIgnored: true }).catch(() => {}); + } }; - }, [active, workspaceId, refreshLoadedDirectory, refreshRoot, refreshTreeGitDecorations]); + }, [active, openWorkspaceIds, refreshLoadedDirectory, refreshRoot, refreshTreeGitDecorations, workspaceId]); /* ---- Open file ---- */ const openFile = useCallback( @@ -649,6 +721,9 @@ export function FilesWorkbench({ isPartial: content.isPartial, }); const tab: EditorTab = { + id: editorTabId(workspaceId, path), + workspaceId, + laneId: workspace?.laneId ?? null, path, title: path.split("/").pop() ?? path, viewerKind, @@ -662,7 +737,19 @@ export function FilesWorkbench({ setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, applyGroups, sessionKey], + [workspace, workspaceId, applyGroups, sessionKey], + ); + + const handleActivateTab = useCallback( + (groupId: string, tabId: string) => { + const tab = allOpenTabs.find((candidate) => candidate.id === tabId); + applyGroups((s) => activateTab(s, groupId, tabId)); + if (tab && tab.workspaceId !== workspaceIdRef.current) { + setWorkspaceId(tab.workspaceId); + setSelectedNodePath(tab.path); + } + }, + [allOpenTabs, applyGroups], ); const openExternalPathRequest = useCallback( @@ -720,32 +807,37 @@ export function FilesWorkbench({ /* ---- Group/tab handlers ---- */ const handleCloseTab = useCallback( - (groupId: string, path: string) => { - if (dirtyPaths.has(path)) { - const ok = window.confirm(`"${path}" has unsaved changes. Close anyway?`); + (groupId: string, tabId: string) => { + const tab = allOpenTabs.find((candidate) => candidate.id === tabId); + if (dirtyTabIds.has(tabId)) { + const label = tab?.path ?? tabId; + const ok = window.confirm(`"${label}" has unsaved changes. Close anyway?`); if (!ok) return; } - registryRef.current.dispose(path); - pruneClosedPathState((candidate) => candidate === path); - applyGroups((s) => closeTab(s, groupId, path)); + const nextState = closeTab(groupsState, groupId, tabId); + if (!isTabOpenInGroups(nextState, tabId)) { + registryRef.current.dispose(tabId); + pruneClosedTabState((candidate) => candidate === tabId); + } + applyGroups(() => nextState); }, - [applyGroups, dirtyPaths, pruneClosedPathState], + [allOpenTabs, applyGroups, dirtyTabIds, groupsState, pruneClosedTabState], ); - const handleDirtyChange = useCallback((path: string, dirty: boolean) => { + const handleDirtyChange = useCallback((tabId: string, dirty: boolean) => { setDirtyBufferRevision((revision) => revision + 1); - setDirtyPaths((prev) => { - const has = prev.has(path); + setDirtyTabIds((prev) => { + const has = prev.has(tabId); if (has === dirty) return prev; const next = new Set(prev); - if (dirty) next.add(path); - else next.delete(path); + if (dirty) next.add(tabId); + else next.delete(tabId); return next; }); }, []); - const handleTabDragStart = useCallback((groupId: string, path: string) => { - dragRef.current = { groupId, path }; + const handleTabDragStart = useCallback((groupId: string, tabId: string) => { + dragRef.current = { groupId, tabId }; setDraggingTab(true); }, []); const handleTabDragEnd = useCallback(() => { @@ -756,7 +848,7 @@ export function FilesWorkbench({ (toGroupId: string) => { const drag = dragRef.current; if (!drag || drag.groupId === toGroupId) return; - applyGroups((s) => moveTabToGroup(s, drag.groupId, toGroupId, drag.path)); + applyGroups((s) => moveTabToGroup(s, drag.groupId, toGroupId, drag.tabId)); }, [applyGroups], ); @@ -770,11 +862,11 @@ export function FilesWorkbench({ if (!drag) return; if (side === "center") { if (drag.groupId !== targetGroupId) { - applyGroups((s) => moveTabToGroup(s, drag.groupId, targetGroupId, drag.path)); + applyGroups((s) => moveTabToGroup(s, drag.groupId, targetGroupId, drag.tabId)); } return; } - applyGroups((s) => splitTabToNewGroup(s, drag.groupId, drag.path, targetGroupId, side)); + applyGroups((s) => splitTabToNewGroup(s, drag.groupId, drag.tabId, targetGroupId, side)); }, [applyGroups], ); @@ -783,38 +875,56 @@ export function FilesWorkbench({ // their models. Matching paths are computed from the current snapshot before // applying, so dispose isn't run inside a reducer. const closeOpenTabsUnder = useCallback( - (target: string) => { - const toClose: Array<{ gid: string; path: string }> = []; + (targetWorkspaceId: string, target: string) => { + const toClose: Array<{ gid: string; tabId: string }> = []; for (const gid of groupsState.groupOrder) { const g = groupsState.groups[gid]; if (!g) continue; - for (const t of g.tabs) if (pathIsAtOrUnder(t.path, target)) toClose.push({ gid, path: t.path }); + for (const t of g.tabs) { + if (t.workspaceId === targetWorkspaceId && pathIsAtOrUnder(t.path, target)) { + toClose.push({ gid, tabId: t.id }); + } + } } if (toClose.length === 0) return; - for (const { path } of toClose) registryRef.current.dispose(path); - pruneClosedPathState((path) => pathIsAtOrUnder(path, target)); - applyGroups((s) => toClose.reduce((acc, { gid, path }) => closeTab(acc, gid, path), s)); + let nextState = groupsState; + for (const { gid, tabId } of toClose) { + nextState = closeTab(nextState, gid, tabId); + } + const closedTabIds = new Set(toClose.map((entry) => entry.tabId)); + for (const tabId of closedTabIds) { + if (!isTabOpenInGroups(nextState, tabId)) { + registryRef.current.dispose(tabId); + } + } + pruneClosedTabState((tabId) => closedTabIds.has(tabId) && !isTabOpenInGroups(nextState, tabId)); + applyGroups(() => nextState); }, - [groupsState, pruneClosedPathState, applyGroups], + [groupsState, pruneClosedTabState, applyGroups], ); const handleCloseOthers = useCallback( - (groupId: string, keepPath: string) => { + (groupId: string, keepTabId: string) => { const group = groupsState.groups[groupId]; if (!group) return; const closing = group.tabs - .filter((tab) => tab.path !== keepPath && !tab.pinned) - .map((tab) => tab.path); - const dirtyClosing = closing.filter((path) => dirtyPaths.has(path)); - if (!confirmDiscardDirtyPaths(dirtyClosing, "Close them")) return; - for (const path of closing) registryRef.current.dispose(path); + .filter((tab) => tab.id !== keepTabId && !tab.pinned) + .map((tab) => tab.id); + const dirtyClosing = closing.filter((tabId) => dirtyTabIds.has(tabId)); + if (!confirmDiscardDirtyTabIds(dirtyClosing, "Close them")) return; + const nextState = closeOtherTabs(groupsState, groupId, keepTabId); + for (const tabId of closing) { + if (!isTabOpenInGroups(nextState, tabId)) { + registryRef.current.dispose(tabId); + } + } if (closing.length > 0) { const closingSet = new Set(closing); - pruneClosedPathState((path) => closingSet.has(path)); + pruneClosedTabState((tabId) => closingSet.has(tabId) && !isTabOpenInGroups(nextState, tabId)); } - applyGroups((s) => closeOtherTabs(s, groupId, keepPath)); + applyGroups(() => nextState); }, - [applyGroups, confirmDiscardDirtyPaths, dirtyPaths, groupsState.groups, pruneClosedPathState], + [applyGroups, confirmDiscardDirtyTabIds, dirtyTabIds, groupsState, pruneClosedTabState], ); const renamePath = useCallback( @@ -824,7 +934,7 @@ export function FilesWorkbench({ setError("This workspace is read-only."); return; } - if (!confirmDiscardDirtyPaths(dirtyPathsUnder(sourcePath), "Rename it")) return; + if (!confirmDiscardDirtyTabIds(dirtyTabsUnder(workspaceId, sourcePath), "Rename it")) return; try { await window.ade.files.rename({ workspaceId, oldPath: sourcePath, newPath: destinationPath }); } catch (err) { @@ -832,10 +942,10 @@ export function FilesWorkbench({ return; } forgetRecentFilesUnder(sessionKey, sourcePath); - closeOpenTabsUnder(sourcePath); // old path/tabs are stale after rename + closeOpenTabsUnder(workspaceId, sourcePath); await refreshRoot(); }, - [workspaceId, canEdit, confirmDiscardDirtyPaths, dirtyPathsUnder, sessionKey, closeOpenTabsUnder, refreshRoot], + [canEdit, closeOpenTabsUnder, confirmDiscardDirtyTabIds, dirtyTabsUnder, refreshRoot, sessionKey, workspaceId], ); const deletePath = useCallback( @@ -847,17 +957,17 @@ export function FilesWorkbench({ } const ok = window.confirm(`Delete "${path}"? This cannot be undone.`); if (!ok) return; - if (!confirmDiscardDirtyPaths(dirtyPathsUnder(path), "Delete it")) return; + if (!confirmDiscardDirtyTabIds(dirtyTabsUnder(workspaceId, path), "Delete it")) return; try { await window.ade.files.delete({ workspaceId, path }); forgetRecentFilesUnder(sessionKey, path); - closeOpenTabsUnder(path); + closeOpenTabsUnder(workspaceId, path); await refreshRoot(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, canEdit, confirmDiscardDirtyPaths, dirtyPathsUnder, sessionKey, closeOpenTabsUnder, refreshRoot], + [canEdit, closeOpenTabsUnder, confirmDiscardDirtyTabIds, dirtyTabsUnder, refreshRoot, sessionKey, workspaceId], ); const dirForNode = (menu: FilesExplorerContextMenuEvent): string => @@ -910,7 +1020,7 @@ export function FilesWorkbench({ setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, canEdit, refreshRoot, openFile], + [canEdit, workspaceId, refreshRoot, openFile], ); // Files-scoped keybindings: ⌘P / ⌘⇧F both open the unified in-depth search. @@ -950,8 +1060,6 @@ export function FilesWorkbench({ [draggingTab, openExternalPathRequest], ); - const noop = useCallback(() => {}, []); - if (!workspaceId) { // Only call it "empty" once the workspace list has actually loaded; while it's // still in flight show a quiet loading state on the purple surface (no alarming @@ -988,25 +1096,15 @@ export function FilesWorkbench({ {!embedded ? ( ) : null} - {showEnableEditing ? ( - - ) : null}
void openFile(path, { preview: false })} onSearch={() => setOverlay({ kind: "search", query: "" })} @@ -1044,21 +1142,26 @@ export function FilesWorkbench({ { + const next = toggleFilesTabScope(projectRootPath); + setTabScope(next); + }} + resolveTabContext={resolveTabContext} theme={theme} registry={registryRef.current} - dirtyPaths={dirtyPaths} - reloadTokensByPath={reloadTokensByPath} - onActivateTab={(groupId, path) => applyGroups((s) => activateTab(s, groupId, path))} + dirtyTabIds={dirtyTabIds} + reloadTokensByTabId={reloadTokensByTabId} + onActivateTab={handleActivateTab} onCloseTab={handleCloseTab} onCloseOthers={handleCloseOthers} - onPinTab={(groupId, path) => applyGroups((s) => pinTab(s, groupId, path))} - onSplitTab={(groupId, path) => applyGroups((s) => splitTabToNewGroup(s, groupId, path, groupId, "right"))} - onPromoteTab={(groupId, path) => applyGroups((s) => promoteFromPreview(s, groupId, path))} + onPinTab={(groupId, tabId) => applyGroups((s) => pinTab(s, groupId, tabId))} + onSplitTab={(groupId, tabId) => applyGroups((s) => splitTabToNewGroup(s, groupId, tabId, groupId, "right"))} + onPromoteTab={(groupId, tabId) => applyGroups((s) => promoteFromPreview(s, groupId, tabId))} onFocusGroup={(groupId) => applyGroups((s) => ({ ...s, activeGroupId: groupId }))} onSplit={(groupId) => applyGroups((s) => splitGroup(s, groupId))} onDirtyChange={handleDirtyChange} @@ -1074,11 +1177,13 @@ export function FilesWorkbench({
{treeMenu ? ( diff --git a/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx b/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx index 86812e11b..cf6fd93bc 100644 --- a/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx +++ b/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx @@ -22,9 +22,9 @@ export type ViewerHostProps = { theme: EditorThemeMode; registry: MonacoModelRegistry; reloadToken?: number; - onDirtyChange?: (path: string, dirty: boolean) => void; - onEdit?: (path: string) => void; - onRegisterEditorApi?: (path: string, api: EditorApi | null) => void; + onDirtyChange?: (tabId: string, dirty: boolean) => void; + onEdit?: (tabId: string) => void; + onRegisterEditorApi?: (tabId: string, api: EditorApi | null) => void; onError?: (message: string) => void; }; @@ -57,29 +57,30 @@ export function ViewerHost(props: ViewerHostProps) { onError: props.onError, }; - // Non-code viewers carry per-file view state (zoom, page, scroll), so key them - // by path to reset on file change. CodeViewer is intentionally NOT keyed: one - // stable Monaco instance per group re-binds models via the registry, so tab - // switches are an instant setModel rather than an editor rebuild. + // Non-code viewers carry per-tab view state (zoom, page, scroll), so key them + // by tab id to reset when the active tab identity changes. CodeViewer is + // intentionally NOT keyed: one stable Monaco instance per group re-binds + // models via the registry, so tab switches are an instant setModel rather than + // an editor rebuild. switch (tab.viewerKind) { case "image": - return ; + return ; case "markdown": - return ; + return ; case "csv": - return ; + return ; case "pdf": - return ; + return ; case "audio": - return ; + return ; case "video": - return ; + return ; case "document": - return ; + return ; case "largeText": - return ; + return ; case "binary": - return ; + return ; case "code": default: return ; diff --git a/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.test.ts b/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.test.ts index 2601ec9e1..3943b0fe3 100644 --- a/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.test.ts +++ b/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.test.ts @@ -5,25 +5,43 @@ import { closeTab, createInitialGroupsState, cycleTab, + editorTabId, type EditorTab, type GroupsState, + isTabOpenInGroups, + mergeLegacyLaneSessions, moveTabToGroup, openInGroup, pinTab, promoteFromPreview, splitGroup, splitTabToNewGroup, + upgradeLegacySession, } from "./editorGroupsStore"; -function tab(path: string, overrides: Partial = {}): EditorTab { +const WS_A = "workspace-a"; +const WS_B = "workspace-b"; +const LANE_A = "lane-a"; +const LANE_B = "lane-b"; + +function tab( + path: string, + overrides: Partial & { workspaceId?: string; laneId?: string | null } = {}, +): EditorTab { + const workspaceId = overrides.workspaceId ?? WS_A; + const laneId = overrides.laneId !== undefined ? overrides.laneId : LANE_A; + const { workspaceId: _ws, laneId: _lane, ...rest } = overrides; return { + id: editorTabId(workspaceId, path), + workspaceId, + laneId, path, title: path.split("/").pop() ?? path, viewerKind: "code", languageId: "typescript", preview: false, pinned: false, - ...overrides, + ...rest, }; } @@ -39,7 +57,19 @@ describe("editorGroupsStore reducers", () => { state = openInGroup(state, g1, tab("b.ts")); expect(group(state).tabs.map((t) => t.path)).toEqual(["a.ts", "b.ts"]); - expect(group(state).activeTabId).toBe("b.ts"); + expect(group(state).activeTabId).toBe(editorTabId(WS_A, "b.ts")); + }); + + it("allows the same path in different workspaces", () => { + let state = createInitialGroupsState(); + state = openInGroup(state, g1, tab("shared.ts", { workspaceId: WS_A, laneId: LANE_A })); + state = openInGroup(state, g1, tab("shared.ts", { workspaceId: WS_B, laneId: LANE_B })); + + expect(group(state).tabs).toHaveLength(2); + expect(group(state).tabs.map((t) => t.id)).toEqual([ + editorTabId(WS_A, "shared.ts"), + editorTabId(WS_B, "shared.ts"), + ]); }); it("reuses a single preview slot instead of growing the strip", () => { @@ -47,14 +77,13 @@ describe("editorGroupsStore reducers", () => { state = openInGroup(state, g1, tab("a.ts"), { preview: true }); state = openInGroup(state, g1, tab("b.ts"), { preview: true }); - // The preview tab was replaced, not appended. expect(group(state).tabs.map((t) => t.path)).toEqual(["b.ts"]); expect(group(state).tabs[0]!.preview).toBe(true); }); it("keeps pinned and non-preview tabs when opening previews", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("keep.ts")); // permanent + state = openInGroup(state, g1, tab("keep.ts")); state = openInGroup(state, g1, tab("p1.ts"), { preview: true }); state = openInGroup(state, g1, tab("p2.ts"), { preview: true }); @@ -63,70 +92,80 @@ describe("editorGroupsStore reducers", () => { it("promotes a preview tab to permanent on edit, and pins explicitly", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts"), { preview: true }); - state = promoteFromPreview(state, g1, "a.ts"); + const a = tab("a.ts", { preview: true }); + state = openInGroup(state, g1, a, { preview: true }); + state = promoteFromPreview(state, g1, a.id); expect(group(state).tabs[0]!.preview).toBe(false); - state = openInGroup(state, g1, tab("b.ts"), { preview: true }); - state = pinTab(state, g1, "b.ts"); + const b = tab("b.ts", { preview: true }); + state = openInGroup(state, g1, b, { preview: true }); + state = pinTab(state, g1, b.id); expect(group(state).tabs.find((t) => t.path === "b.ts")).toMatchObject({ pinned: true, preview: false }); }); it("re-opening an open file as non-preview clears its preview flag", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts"), { preview: true }); - state = openInGroup(state, g1, tab("a.ts"), { preview: false }); + const a = tab("a.ts"); + state = openInGroup(state, g1, a, { preview: true }); + state = openInGroup(state, g1, a, { preview: false }); expect(group(state).tabs).toHaveLength(1); expect(group(state).tabs[0]!.preview).toBe(false); }); it("falls back to the most-recently-used tab when closing the active one", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = openInGroup(state, g1, tab("b.ts")); - state = openInGroup(state, g1, tab("c.ts")); - state = activateTab(state, g1, "a.ts"); // MRU: a, c, b - state = activateTab(state, g1, "b.ts"); // MRU: b, a, c - state = closeTab(state, g1, "b.ts"); + const a = tab("a.ts"); + const b = tab("b.ts"); + const c = tab("c.ts"); + state = openInGroup(state, g1, a); + state = openInGroup(state, g1, b); + state = openInGroup(state, g1, c); + state = activateTab(state, g1, a.id); + state = activateTab(state, g1, b.id); + state = closeTab(state, g1, b.id); - expect(group(state).activeTabId).toBe("a.ts"); + expect(group(state).activeTabId).toBe(a.id); expect(group(state).tabs.map((t) => t.path)).toEqual(["a.ts", "c.ts"]); }); it("closeOtherTabs keeps the target and pinned tabs", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = openInGroup(state, g1, tab("b.ts")); - state = openInGroup(state, g1, tab("c.ts")); - state = pinTab(state, g1, "a.ts"); - state = closeOtherTabs(state, g1, "c.ts"); + const a = tab("a.ts"); + const b = tab("b.ts"); + const c = tab("c.ts"); + state = openInGroup(state, g1, a); + state = openInGroup(state, g1, b); + state = openInGroup(state, g1, c); + state = pinTab(state, g1, a.id); + state = closeOtherTabs(state, g1, c.id); expect(group(state).tabs.map((t) => t.path).sort()).toEqual(["a.ts", "c.ts"]); - expect(group(state).activeTabId).toBe("c.ts"); + expect(group(state).activeTabId).toBe(c.id); }); it("splits into a new group seeded with the active tab", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); + const a = tab("a.ts"); + state = openInGroup(state, g1, a); state = splitGroup(state, g1); expect(state.groupOrder).toHaveLength(2); const newId = state.groupOrder[1]!; expect(state.activeGroupId).toBe(newId); expect(group(state, newId).tabs.map((t) => t.path)).toEqual(["a.ts"]); - // Original group is untouched. expect(group(state, g1).tabs.map((t) => t.path)).toEqual(["a.ts"]); }); it("moves a tab between groups and removes an emptied source group", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = splitGroup(state, g1); // new group with a.ts active + const a = tab("a.ts"); + const b = tab("b.ts"); + state = openInGroup(state, g1, a); + state = splitGroup(state, g1); const g2 = state.groupOrder[1]!; - state = openInGroup(state, g2, tab("b.ts")); // g2 now has a.ts, b.ts + state = openInGroup(state, g2, b); - // Move b.ts back to g1. - state = moveTabToGroup(state, g2, g1, "b.ts"); + state = moveTabToGroup(state, g2, g1, b.id); expect(group(state, g1).tabs.map((t) => t.path)).toEqual(["a.ts", "b.ts"]); expect(group(state, g2).tabs.map((t) => t.path)).toEqual(["a.ts"]); expect(state.groupOrder).toHaveLength(2); @@ -134,11 +173,11 @@ describe("editorGroupsStore reducers", () => { it("removes the group when its last tab moves away", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); + const a = tab("a.ts"); + state = openInGroup(state, g1, a); state = splitGroup(state, g1); const g2 = state.groupOrder[1]!; - // g2 has only a.ts; move it back to g1 → g2 should be removed. - state = moveTabToGroup(state, g2, g1, "a.ts"); + state = moveTabToGroup(state, g2, g1, a.id); expect(state.groupOrder).toEqual([g1]); expect(state.groups[g2]).toBeUndefined(); expect(state.activeGroupId).toBe(g1); @@ -146,14 +185,15 @@ describe("editorGroupsStore reducers", () => { it("drag-splits a tab into a new group on the requested side", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = openInGroup(state, g1, tab("b.ts")); - // Drag b.ts out to the right of group-1 → new group to the right. - state = splitTabToNewGroup(state, g1, "b.ts", g1, "right"); + const a = tab("a.ts"); + const b = tab("b.ts"); + state = openInGroup(state, g1, a); + state = openInGroup(state, g1, b); + state = splitTabToNewGroup(state, g1, b.id, g1, "right"); expect(state.groupOrder).toHaveLength(2); const newId = state.groupOrder[1]!; - expect(state.groupOrder).toEqual([g1, newId]); // new group is to the right + expect(state.groupOrder).toEqual([g1, newId]); expect(group(state, g1).tabs.map((t) => t.path)).toEqual(["a.ts"]); expect(group(state, newId).tabs.map((t) => t.path)).toEqual(["b.ts"]); expect(state.activeGroupId).toBe(newId); @@ -161,9 +201,11 @@ describe("editorGroupsStore reducers", () => { it("drag-split to the left inserts the new group before the anchor", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = openInGroup(state, g1, tab("b.ts")); - state = splitTabToNewGroup(state, g1, "b.ts", g1, "left"); + const a = tab("a.ts"); + const b = tab("b.ts"); + state = openInGroup(state, g1, a); + state = openInGroup(state, g1, b); + state = splitTabToNewGroup(state, g1, b.id, g1, "left"); const newId = state.groupOrder[0]!; expect(state.groupOrder).toEqual([newId, g1]); expect(group(state, newId).tabs.map((t) => t.path)).toEqual(["b.ts"]); @@ -171,29 +213,99 @@ describe("editorGroupsStore reducers", () => { it("drag-split is a no-op when the source group has a single tab", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("only.ts")); - const next = splitTabToNewGroup(state, g1, "only.ts", g1, "right"); + const only = tab("only.ts"); + state = openInGroup(state, g1, only); + const next = splitTabToNewGroup(state, g1, only.id, g1, "right"); expect(next).toBe(state); expect(next.groupOrder).toEqual([g1]); }); it("cycles tabs by document order", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = openInGroup(state, g1, tab("b.ts")); - state = openInGroup(state, g1, tab("c.ts")); // active c - state = cycleTab(state, g1, 1); // wraps to a - expect(group(state).activeTabId).toBe("a.ts"); - state = cycleTab(state, g1, -1); // back to c - expect(group(state).activeTabId).toBe("c.ts"); + const a = tab("a.ts"); + const b = tab("b.ts"); + const c = tab("c.ts"); + state = openInGroup(state, g1, a); + state = openInGroup(state, g1, b); + state = openInGroup(state, g1, c); + state = cycleTab(state, g1, 1); + expect(group(state).activeTabId).toBe(a.id); + state = cycleTab(state, g1, -1); + expect(group(state).activeTabId).toBe(c.id); }); it("closing the only tab keeps the sole group present but empty", () => { let state = createInitialGroupsState(); - state = openInGroup(state, g1, tab("a.ts")); - state = closeTab(state, g1, "a.ts"); + const a = tab("a.ts"); + state = openInGroup(state, g1, a); + state = closeTab(state, g1, a.id); expect(state.groupOrder).toEqual([g1]); expect(group(state).tabs).toEqual([]); expect(group(state).activeTabId).toBeNull(); }); + + it("merges simple legacy per-lane sessions into one tab strip", () => { + const sessionA = openInGroup(createInitialGroupsState(), g1, tab("a.ts", { workspaceId: WS_A })); + const sessionB = openInGroup(createInitialGroupsState(), g1, tab("b.ts", { workspaceId: WS_B })); + const merged = mergeLegacyLaneSessions([sessionA, sessionB]); + expect(merged.groups[g1]!.tabs.map((t) => t.path).sort()).toEqual(["a.ts", "b.ts"]); + }); + + it("preserves duplicate split panes during legacy session merge", () => { + const existing = openInGroup(createInitialGroupsState(), g1, tab("existing.ts", { workspaceId: WS_B })); + let splitSession = openInGroup(createInitialGroupsState(), g1, tab("split.ts", { workspaceId: WS_A })); + splitSession = splitGroup(splitSession, g1); + + const merged = mergeLegacyLaneSessions([existing, splitSession]); + const splitTabId = editorTabId(WS_A, "split.ts"); + const groupsWithSplitTab = Object.values(merged.groups).filter((entry) => + entry.tabs.some((entryTab) => entryTab.id === splitTabId), + ); + + expect(groupsWithSplitTab).toHaveLength(2); + expect(groupsWithSplitTab.every((entry) => entry.activeTabId === splitTabId)).toBe(true); + }); + + it("upgrades legacy path-based session fields to tab ids", () => { + const legacy = createInitialGroupsState(); + legacy.groups[g1] = { + id: g1, + tabs: [ + { + path: "src/legacy.ts", + title: "legacy.ts", + viewerKind: "code", + languageId: "typescript", + preview: false, + pinned: false, + } as EditorTab, + ], + activeTabId: "src/legacy.ts", + recentTabIds: ["src/legacy.ts"], + }; + + const upgraded = upgradeLegacySession(legacy, WS_B, LANE_B); + const upgradedTabId = editorTabId(WS_B, "src/legacy.ts"); + + expect(group(upgraded).tabs[0]).toMatchObject({ + id: upgradedTabId, + workspaceId: WS_B, + laneId: LANE_B, + path: "src/legacy.ts", + }); + expect(group(upgraded).activeTabId).toBe(upgradedTabId); + expect(group(upgraded).recentTabIds).toEqual([upgradedTabId]); + }); + + it("isTabOpenInGroups detects duplicate split panes", () => { + let state = createInitialGroupsState(); + const a = tab("a.ts"); + state = openInGroup(state, g1, a); + state = splitGroup(state, g1); + expect(isTabOpenInGroups(state, a.id)).toBe(true); + state = closeTab(state, state.groupOrder[1]!, a.id); + expect(isTabOpenInGroups(state, a.id)).toBe(true); + state = closeTab(state, g1, a.id); + expect(isTabOpenInGroups(state, a.id)).toBe(false); + }); }); diff --git a/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.ts b/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.ts index 321f76c81..237436978 100644 --- a/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.ts +++ b/apps/desktop/src/renderer/components/files/v2/editorGroupsStore.ts @@ -10,8 +10,8 @@ import { create } from "zustand"; * it lives in the Monaco model registry / is streamed on demand. * * All mutation logic is expressed as pure reducer functions (exported for unit - * tests); the Zustand store is a thin wrapper that applies them per `sessionKey` - * (`${projectRoot}::${laneId}`), so switching lanes restores the exact tab set. + * tests); the Zustand store is a thin wrapper that applies them per project-level + * `sessionKey`, so open tabs persist across lane switches. */ export type ViewerKind = @@ -29,7 +29,11 @@ export type ViewerKind = | "conflict"; export type EditorTab = { - /** Workspace-relative path; also the tab's identity within a group. */ + /** Composite identity: `${workspaceId}::${path}`. */ + id: string; + workspaceId: string; + laneId: string | null; + /** Workspace-relative path. */ path: string; title: string; viewerKind: ViewerKind; @@ -40,12 +44,16 @@ export type EditorTab = { pinned: boolean; }; +export function editorTabId(workspaceId: string, path: string): string { + return `${workspaceId}::${path}`; +} + export type EditorGroup = { id: string; tabs: EditorTab[]; - /** Active tab path, or null when the group is empty. */ + /** Active tab id, or null when the group is empty. */ activeTabId: string | null; - /** Most-recently-active paths (front = most recent) for Ctrl+Tab + close fallback. */ + /** Most-recently-active tab ids (front = most recent) for Ctrl+Tab + close fallback. */ recentTabIds: string[]; }; @@ -71,8 +79,8 @@ function nextGroupId(state: GroupsState): string { return `group-${n}`; } -function touchRecent(recent: string[], path: string): string[] { - return [path, ...recent.filter((p) => p !== path)]; +function touchRecent(recent: string[], tabId: string): string[] { + return [tabId, ...recent.filter((id) => id !== tabId)]; } function withGroup(state: GroupsState, groupId: string, update: (g: EditorGroup) => EditorGroup): GroupsState { @@ -82,11 +90,13 @@ function withGroup(state: GroupsState, groupId: string, update: (g: EditorGroup) } /** Recompute a sensible active tab after the current one is removed. */ -function pickActiveAfterRemoval(group: EditorGroup, removedPath: string): string | null { - const recentCandidate = group.recentTabIds.find((p) => p !== removedPath && group.tabs.some((t) => t.path === p)); +function pickActiveAfterRemoval(group: EditorGroup, removedTabId: string): string | null { + const recentCandidate = group.recentTabIds.find( + (id) => id !== removedTabId && group.tabs.some((t) => t.id === id), + ); if (recentCandidate) return recentCandidate; - const remaining = group.tabs.filter((t) => t.path !== removedPath); - return remaining[remaining.length - 1]?.path ?? null; + const remaining = group.tabs.filter((t) => t.id !== removedTabId); + return remaining[remaining.length - 1]?.id ?? null; } export function openInGroup( @@ -98,13 +108,13 @@ export function openInGroup( const group = state.groups[groupId]; if (!group) return state; const preview = opts.preview ?? false; - const existing = group.tabs.find((t) => t.path === tab.path); + const existing = group.tabs.find((t) => t.id === tab.id); let nextTabs: EditorTab[]; if (existing) { // Already open: opening non-preview promotes it out of the preview slot. nextTabs = group.tabs.map((t) => - t.path === tab.path ? { ...t, preview: preview ? t.preview : false } : t, + t.id === tab.id ? { ...t, preview: preview ? t.preview : false } : t, ); } else if (preview) { // Replace the existing preview slot (if any) rather than growing the strip. @@ -123,50 +133,50 @@ export function openInGroup( ...withGroup(state, groupId, (g) => ({ ...g, tabs: nextTabs, - activeTabId: tab.path, - recentTabIds: touchRecent(g.recentTabIds, tab.path), + activeTabId: tab.id, + recentTabIds: touchRecent(g.recentTabIds, tab.id), })), activeGroupId: groupId, }; } -export function activateTab(state: GroupsState, groupId: string, path: string): GroupsState { +export function activateTab(state: GroupsState, groupId: string, tabId: string): GroupsState { return { ...withGroup(state, groupId, (g) => - g.tabs.some((t) => t.path === path) - ? { ...g, activeTabId: path, recentTabIds: touchRecent(g.recentTabIds, path) } + g.tabs.some((t) => t.id === tabId) + ? { ...g, activeTabId: tabId, recentTabIds: touchRecent(g.recentTabIds, tabId) } : g, ), activeGroupId: state.groups[groupId] ? groupId : state.activeGroupId, }; } -export function pinTab(state: GroupsState, groupId: string, path: string): GroupsState { +export function pinTab(state: GroupsState, groupId: string, tabId: string): GroupsState { return withGroup(state, groupId, (g) => ({ ...g, - tabs: g.tabs.map((t) => (t.path === path ? { ...t, pinned: true, preview: false } : t)), + tabs: g.tabs.map((t) => (t.id === tabId ? { ...t, pinned: true, preview: false } : t)), })); } /** First edit promotes a preview tab to a permanent (non-preview) tab. */ -export function promoteFromPreview(state: GroupsState, groupId: string, path: string): GroupsState { +export function promoteFromPreview(state: GroupsState, groupId: string, tabId: string): GroupsState { return withGroup(state, groupId, (g) => ({ ...g, - tabs: g.tabs.map((t) => (t.path === path && t.preview ? { ...t, preview: false } : t)), + tabs: g.tabs.map((t) => (t.id === tabId && t.preview ? { ...t, preview: false } : t)), })); } -export function closeTab(state: GroupsState, groupId: string, path: string): GroupsState { +export function closeTab(state: GroupsState, groupId: string, tabId: string): GroupsState { const group = state.groups[groupId]; - if (!group || !group.tabs.some((t) => t.path === path)) return state; + if (!group || !group.tabs.some((t) => t.id === tabId)) return state; - const nextTabs = group.tabs.filter((t) => t.path !== path); - const nextActive = group.activeTabId === path ? pickActiveAfterRemoval(group, path) : group.activeTabId; + const nextTabs = group.tabs.filter((t) => t.id !== tabId); + const nextActive = group.activeTabId === tabId ? pickActiveAfterRemoval(group, tabId) : group.activeTabId; const updatedGroup: EditorGroup = { ...group, tabs: nextTabs, activeTabId: nextActive, - recentTabIds: group.recentTabIds.filter((p) => p !== path), + recentTabIds: group.recentTabIds.filter((id) => id !== tabId), }; // Remove an emptied group unless it is the only one. @@ -181,31 +191,35 @@ export function closeTab(state: GroupsState, groupId: string, path: string): Gro return { ...state, groups: { ...state.groups, [groupId]: updatedGroup } }; } -export function closeOtherTabs(state: GroupsState, groupId: string, keepPath: string): GroupsState { +export function closeOtherTabs(state: GroupsState, groupId: string, keepTabId: string): GroupsState { return withGroup(state, groupId, (g) => { - const kept = g.tabs.filter((t) => t.path === keepPath || t.pinned); + const kept = g.tabs.filter((t) => t.id === keepTabId || t.pinned); return { ...g, tabs: kept, - activeTabId: kept.some((t) => t.path === keepPath) ? keepPath : kept[kept.length - 1]?.path ?? null, - recentTabIds: g.recentTabIds.filter((p) => kept.some((t) => t.path === p)), + activeTabId: kept.some((t) => t.id === keepTabId) ? keepTabId : kept[kept.length - 1]?.id ?? null, + recentTabIds: g.recentTabIds.filter((id) => kept.some((t) => t.id === id)), }; }); } +export function isTabOpenInGroups(state: GroupsState, tabId: string): boolean { + return Object.values(state.groups).some((group) => group.tabs.some((tab) => tab.id === tabId)); +} + /** Split: create a new group to the side seeded with the source group's active tab. */ export function splitGroup(state: GroupsState, fromGroupId: string): GroupsState { const from = state.groups[fromGroupId]; if (!from || !from.activeTabId) return state; - const seedTab = from.tabs.find((t) => t.path === from.activeTabId); + const seedTab = from.tabs.find((t) => t.id === from.activeTabId); if (!seedTab) return state; const newId = nextGroupId(state); const newGroup: EditorGroup = { id: newId, tabs: [{ ...seedTab, preview: false }], - activeTabId: seedTab.path, - recentTabIds: [seedTab.path], + activeTabId: seedTab.id, + recentTabIds: [seedTab.id], }; const fromIdx = state.groupOrder.indexOf(fromGroupId); const nextOrder = [...state.groupOrder]; @@ -221,17 +235,17 @@ export function moveTabToGroup( state: GroupsState, fromGroupId: string, toGroupId: string, - path: string, + tabId: string, ): GroupsState { - if (fromGroupId === toGroupId) return activateTab(state, toGroupId, path); + if (fromGroupId === toGroupId) return activateTab(state, toGroupId, tabId); const from = state.groups[fromGroupId]; const to = state.groups[toGroupId]; if (!from || !to) return state; - const tab = from.tabs.find((t) => t.path === path); + const tab = from.tabs.find((t) => t.id === tabId); if (!tab) return state; // Remove from source (closeTab handles emptied-group removal + active fallback). - let next = closeTab(state, fromGroupId, path); + let next = closeTab(state, fromGroupId, tabId); // Source group may have been removed; only re-add if target still exists. if (!next.groups[toGroupId]) return next; next = openInGroup(next, toGroupId, { ...tab, preview: false }, { preview: false }); @@ -247,13 +261,13 @@ export function moveTabToGroup( export function splitTabToNewGroup( state: GroupsState, fromGroupId: string, - path: string, + tabId: string, anchorGroupId: string, side: "left" | "right", ): GroupsState { const from = state.groups[fromGroupId]; if (!from) return state; - const tab = from.tabs.find((t) => t.path === path); + const tab = from.tabs.find((t) => t.id === tabId); if (!tab) return state; if (from.tabs.length <= 1 && fromGroupId === anchorGroupId) return state; // nothing to split off @@ -261,12 +275,12 @@ export function splitTabToNewGroup( const newGroup: EditorGroup = { id: newId, tabs: [{ ...tab, preview: false }], - activeTabId: tab.path, - recentTabIds: [tab.path], + activeTabId: tab.id, + recentTabIds: [tab.id], }; // Remove the tab from its source (collapses an emptied, non-sole group). - const afterClose = closeTab(state, fromGroupId, path); + const afterClose = closeTab(state, fromGroupId, tabId); const order = [...afterClose.groupOrder]; const anchorIdx = order.indexOf(anchorGroupId); const insertAt = anchorIdx < 0 ? order.length : side === "left" ? anchorIdx : anchorIdx + 1; @@ -287,12 +301,118 @@ export function focusGroup(state: GroupsState, groupId: string): GroupsState { export function cycleTab(state: GroupsState, groupId: string, direction: 1 | -1): GroupsState { const group = state.groups[groupId]; if (!group || group.tabs.length < 2) return state; - const order = group.tabs.map((t) => t.path); + const order = group.tabs.map((t) => t.id); const currentIdx = group.activeTabId ? order.indexOf(group.activeTabId) : 0; const nextIdx = (currentIdx + direction + order.length) % order.length; return activateTab(state, groupId, order[nextIdx]!); } +/** Upgrade tabs from legacy per-lane sessions that only stored `path`. */ +export function upgradeLegacySession( + session: GroupsState, + workspaceId: string, + laneId: string | null, +): GroupsState { + let next = session; + for (const groupId of session.groupOrder) { + const group = session.groups[groupId]; + if (!group) continue; + const upgradedTabs = group.tabs.map((raw) => { + const partial = raw as Partial & { path: string }; + const id = partial.id ?? editorTabId(workspaceId, partial.path); + return { + id, + workspaceId: partial.workspaceId ?? workspaceId, + laneId: partial.laneId !== undefined ? partial.laneId : laneId, + path: partial.path, + title: partial.title ?? partial.path.split("/").pop() ?? partial.path, + viewerKind: partial.viewerKind ?? "code", + languageId: partial.languageId ?? "plaintext", + preview: partial.preview ?? false, + pinned: partial.pinned ?? false, + } satisfies EditorTab; + }); + const pathToId = new Map(upgradedTabs.map((t) => [t.path, t.id])); + const activeTabId = group.activeTabId + ? (pathToId.get(group.activeTabId) ?? group.activeTabId) + : null; + const recentTabIds = group.recentTabIds.map((entry) => pathToId.get(entry) ?? entry); + next = withGroup(next, groupId, () => ({ + ...group, + tabs: upgradedTabs, + activeTabId, + recentTabIds, + })); + } + return next; +} + +/** Merge tabs from legacy per-lane sessions into a single project-level state. */ +export function mergeLegacyLaneSessions(sessions: GroupsState[]): GroupsState { + if (sessions.length === 0) return createInitialGroupsState(); + if (sessions.length === 1) return sessions[0]!; + + let merged = createInitialGroupsState(); + const ensureTargetGroup = (state: GroupsState, useSharedGroup: boolean): { state: GroupsState; groupId: string } => { + if (useSharedGroup) return { state, groupId: FIRST_GROUP_ID }; + const firstGroup = state.groups[FIRST_GROUP_ID]; + if (state.groupOrder.length === 1 && firstGroup && firstGroup.tabs.length === 0) { + return { state, groupId: FIRST_GROUP_ID }; + } + const groupId = nextGroupId(state); + return { + state: { + ...state, + groups: { + ...state.groups, + [groupId]: { id: groupId, tabs: [], activeTabId: null, recentTabIds: [] }, + }, + groupOrder: [...state.groupOrder, groupId], + }, + groupId, + }; + }; + + const restoreGroupSelection = (state: GroupsState, groupId: string, sourceGroup: EditorGroup): GroupsState => { + const targetGroup = state.groups[groupId]; + if (!targetGroup) return state; + const tabIds = new Set(targetGroup.tabs.map((tab) => tab.id)); + const sourceRecent = sourceGroup.recentTabIds.filter((tabId) => tabIds.has(tabId)); + const generatedRecent = targetGroup.recentTabIds.filter((tabId) => tabIds.has(tabId) && !sourceRecent.includes(tabId)); + const activeTabId = sourceGroup.activeTabId && tabIds.has(sourceGroup.activeTabId) + ? sourceGroup.activeTabId + : targetGroup.activeTabId; + return { + ...state, + activeGroupId: activeTabId ? groupId : state.activeGroupId, + groups: { + ...state.groups, + [groupId]: { + ...targetGroup, + activeTabId, + recentTabIds: [...sourceRecent, ...generatedRecent], + }, + }, + }; + }; + + for (const session of sessions) { + const sourceGroups = session.groupOrder + .map((groupId) => session.groups[groupId]) + .filter((group): group is EditorGroup => Boolean(group && group.tabs.length > 0)); + const preserveSourceGroups = sourceGroups.length > 1; + for (const group of sourceGroups) { + const target = ensureTargetGroup(merged, !preserveSourceGroups); + merged = target.state; + for (const tab of group.tabs) { + merged = openInGroup(merged, target.groupId, tab, { preview: false }); + } + merged = restoreGroupSelection(merged, target.groupId, group); + } + } + return merged; +} + /* ---- Zustand store: one GroupsState per sessionKey ---- */ type EditorGroupsStore = { diff --git a/apps/desktop/src/renderer/components/files/v2/filesTabScope.test.ts b/apps/desktop/src/renderer/components/files/v2/filesTabScope.test.ts new file mode 100644 index 000000000..660e961fa --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/filesTabScope.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadScopeModule() { + vi.resetModules(); + return import("./filesTabScope"); +} + +describe("filesTabScope", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + }); + + it("defaults to all lanes", async () => { + const { getFilesTabScope } = await loadScopeModule(); + expect(getFilesTabScope("/tmp/project")).toBe("all"); + }); + + it("persists scope per project", async () => { + const { getFilesTabScope, setFilesTabScope } = await loadScopeModule(); + setFilesTabScope("/tmp/project-a", "lane"); + expect(getFilesTabScope("/tmp/project-a")).toBe("lane"); + expect(getFilesTabScope("/tmp/project-b")).toBe("all"); + + const reloaded = await loadScopeModule(); + expect(reloaded.getFilesTabScope("/tmp/project-a")).toBe("lane"); + expect(reloaded.getFilesTabScope("/tmp/project-b")).toBe("all"); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/v2/filesTabScope.ts b/apps/desktop/src/renderer/components/files/v2/filesTabScope.ts new file mode 100644 index 000000000..7492c38b2 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/filesTabScope.ts @@ -0,0 +1,42 @@ +export type FilesTabScope = "all" | "lane"; + +const STORAGE_PREFIX = "ade.files.tabScope:"; +const scopeByProject = new Map(); + +function storageKey(projectRoot: string): string { + return `${STORAGE_PREFIX}${projectRoot}`; +} + +function readStoredScope(projectRoot: string): FilesTabScope | null { + try { + const raw = localStorage.getItem(storageKey(projectRoot)); + if (raw === "all" || raw === "lane") return raw; + } catch { + // ignore + } + return null; +} + +export function getFilesTabScope(projectRoot: string): FilesTabScope { + const cached = scopeByProject.get(projectRoot); + if (cached) return cached; + const stored = readStoredScope(projectRoot); + const scope = stored ?? "all"; + scopeByProject.set(projectRoot, scope); + return scope; +} + +export function setFilesTabScope(projectRoot: string, scope: FilesTabScope): void { + scopeByProject.set(projectRoot, scope); + try { + localStorage.setItem(storageKey(projectRoot), scope); + } catch { + // ignore + } +} + +export function toggleFilesTabScope(projectRoot: string): FilesTabScope { + const next: FilesTabScope = getFilesTabScope(projectRoot) === "all" ? "lane" : "all"; + setFilesTabScope(projectRoot, next); + return next; +} diff --git a/apps/desktop/src/renderer/components/files/v2/tabDisplayOrder.test.ts b/apps/desktop/src/renderer/components/files/v2/tabDisplayOrder.test.ts new file mode 100644 index 000000000..1da5abee9 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/tabDisplayOrder.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { editorTabId } from "./editorGroupsStore"; +import { filterTabsForScope, orderTabsByLane } from "./tabDisplayOrder"; + +const WS = "ws-1"; +const OTHER_WS = "ws-2"; + +function tab(path: string, laneId: string | null) { + return { + id: editorTabId(WS, path), + workspaceId: WS, + laneId, + path, + title: path, + viewerKind: "code" as const, + languageId: "typescript", + preview: false, + pinned: false, + }; +} + +describe("tabDisplayOrder", () => { + it("orders tabs by lane order", () => { + const lanes = [ + { id: "lane-a", color: "#f00" } as const, + { id: "lane-b", color: "#0f0" } as const, + ]; + const ordered = orderTabsByLane( + [tab("b.ts", "lane-b"), tab("a.ts", "lane-a"), tab("primary.ts", null)], + lanes as never, + ); + expect(ordered.map((entry) => entry.path)).toEqual(["primary.ts", "a.ts", "b.ts"]); + }); + + it("filters tabs to the current lane in lane-only scope", () => { + const tabs = [tab("a.ts", "lane-a"), tab("b.ts", "lane-b")]; + expect(filterTabsForScope(tabs, "lane", "lane-a", WS).map((entry) => entry.path)).toEqual(["a.ts"]); + expect(filterTabsForScope(tabs, "all", "lane-a", WS).map((entry) => entry.path)).toEqual(["a.ts", "b.ts"]); + }); + + it("uses workspace identity for lane-less lane-only scope", () => { + const tabs = [ + tab("primary.ts", null), + { ...tab("external.ts", null), id: editorTabId(OTHER_WS, "external.ts"), workspaceId: OTHER_WS }, + ]; + + expect(filterTabsForScope(tabs, "lane", null, WS).map((entry) => entry.path)).toEqual(["primary.ts"]); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/v2/tabDisplayOrder.ts b/apps/desktop/src/renderer/components/files/v2/tabDisplayOrder.ts new file mode 100644 index 000000000..2957b1244 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/tabDisplayOrder.ts @@ -0,0 +1,41 @@ +import type { LaneSummary } from "../../../../shared/types"; +import type { EditorTab } from "./editorGroupsStore"; + +function laneOrderIndex(laneId: string | null, lanes: readonly LaneSummary[]): number { + if (laneId == null) return -1; + const idx = lanes.findIndex((lane) => lane.id === laneId); + return idx >= 0 ? idx : lanes.length; +} + +/** Group tabs by lane (stable lane order) while preserving within-lane open order. */ +export function orderTabsByLane(tabs: readonly EditorTab[], lanes: readonly LaneSummary[]): EditorTab[] { + if (tabs.length <= 1) return [...tabs]; + const laneBuckets = new Map(); + for (const tab of tabs) { + const bucket = laneBuckets.get(tab.laneId) ?? []; + bucket.push(tab); + laneBuckets.set(tab.laneId, bucket); + } + const laneKeys = [...laneBuckets.keys()].sort((a, b) => laneOrderIndex(a, lanes) - laneOrderIndex(b, lanes)); + return laneKeys.flatMap((laneId) => laneBuckets.get(laneId) ?? []); +} + +export function filterTabsForScope( + tabs: readonly EditorTab[], + scope: "all" | "lane", + currentLaneId: string | null, + currentWorkspaceId: string, +): EditorTab[] { + if (scope === "all") return [...tabs]; + return tabs.filter((tab) => + currentLaneId != null ? tab.laneId === currentLaneId : tab.workspaceId === currentWorkspaceId, + ); +} + +export function isLaneGroupBoundary(tabs: readonly EditorTab[], index: number): boolean { + if (index <= 0) return false; + const prev = tabs[index - 1]; + const curr = tabs[index]; + if (!prev || !curr) return false; + return prev.laneId !== curr.laneId; +} diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx b/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx index a8f7237e8..0d047bcdb 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx +++ b/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx @@ -33,37 +33,33 @@ export function CodeViewer({ ctxRef.current = { workspaceId, tab, registry, onDirtyChange, onEdit, onRegisterEditorApi, onError, readOnly }; const apiRef = useRef(null); - const registeredPathRef = useRef(null); + const registeredTabIdRef = useRef(null); const save = useRef(async () => { const { workspaceId: ws, tab: t, registry: reg, onDirtyChange: onDirty } = ctxRef.current; const editor = editorRef.current; if (!editor) return; - if (!ctxRef.current.readOnly) { - try { - await editor.getAction("editor.action.formatDocument")?.run(); - } catch { - // formatter may be unavailable for this language — save unformatted - } + if (ctxRef.current.readOnly) return; + try { + await editor.getAction("editor.action.formatDocument")?.run(); + } catch { + // formatter may be unavailable for this language — save unformatted } - const text = reg.getValue(t.path) ?? editor.getValue(); + const text = reg.getValue(t.id) ?? editor.getValue(); await window.ade.files.writeText({ workspaceId: ws, path: t.path, text }); - reg.markSaved(t.path); - onDirty?.(t.path, false); + reg.markSaved(t.id); + onDirty?.(t.id, false); }).current; - // This editor instance is reused across tab switches (no per-path remount), so - // the editor API must be (re)registered under whichever path is now active and - // unregistered from the previous one. - const registerApiForActivePath = useRef(() => { + const registerApiForActiveTab = useRef(() => { const api = apiRef.current; if (!api) return; const { tab: t, onRegisterEditorApi: register } = ctxRef.current; - if (registeredPathRef.current && registeredPathRef.current !== t.path) { - register?.(registeredPathRef.current, null); + if (registeredTabIdRef.current && registeredTabIdRef.current !== t.id) { + register?.(registeredTabIdRef.current, null); } - register?.(t.path, api); - registeredPathRef.current = t.path; + register?.(t.id, api); + registeredTabIdRef.current = t.id; }).current; // Create the editor once per mount. @@ -112,7 +108,7 @@ export function CodeViewer({ }, }; apiRef.current = api; - registerApiForActivePath(); + registerApiForActiveTab(); attachModel(monaco, editor); }); @@ -121,9 +117,9 @@ export function CodeViewer({ disposed = true; changeSubRef.current?.dispose(); changeSubRef.current = null; - if (registeredPathRef.current) { - ctxRef.current.onRegisterEditorApi?.(registeredPathRef.current, null); - registeredPathRef.current = null; + if (registeredTabIdRef.current) { + ctxRef.current.onRegisterEditorApi?.(registeredTabIdRef.current, null); + registeredTabIdRef.current = null; } apiRef.current = null; try { @@ -143,9 +139,9 @@ export function CodeViewer({ const editor = editorRef.current; if (!monaco || !editor) return; attachModel(monaco, editor); - registerApiForActivePath(); + registerApiForActiveTab(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab.path, content.content, content.languageId]); + }, [tab.id, content.content, content.languageId]); // React to readOnly / theme without recreating the editor. useEffect(() => { @@ -157,18 +153,18 @@ export function CodeViewer({ function attachModel(monaco: typeof Monaco, editor: Monaco.editor.IStandaloneCodeEditor) { const language = resolveLanguageId(tab.path, content.languageId); - registry.refreshClean(monaco, tab.path, content.content, language); - const model = registry.getOrCreate(monaco, tab.path, content.content, language); + registry.refreshClean(monaco, tab.id, content.content, language); + const model = registry.getOrCreate(monaco, tab.id, content.content, language); if (editor.getModel() !== model) { editor.setModel(model); } changeSubRef.current?.dispose(); changeSubRef.current = model.onDidChangeContent(() => { const { tab: t, registry: reg, onDirtyChange: onDirty, onEdit: onEditCb } = ctxRef.current; - onEditCb?.(t.path); // first edit promotes a preview tab to permanent + onEditCb?.(t.id); // first edit promotes a preview tab to permanent if (dirtyTimerRef.current) clearTimeout(dirtyTimerRef.current); dirtyTimerRef.current = setTimeout(() => { - onDirty?.(t.path, reg.isDirty(t.path)); + onDirty?.(t.id, reg.isDirty(t.id)); }, 120); }); // Jump to a line requested by the search overlay (one-shot). diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/MarkdownViewer.tsx b/apps/desktop/src/renderer/components/files/v2/viewers/MarkdownViewer.tsx index e99e2050a..2fa2bea07 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/MarkdownViewer.tsx +++ b/apps/desktop/src/renderer/components/files/v2/viewers/MarkdownViewer.tsx @@ -20,7 +20,7 @@ export function MarkdownViewer(props: ViewerProps) { const { registry, tab, content } = props; // Preview reflects the live model value (incl. unsaved edits) when present. - const previewText = mode === "preview" ? registry.getValue(tab.path) ?? content.content : ""; + const previewText = mode === "preview" ? registry.getValue(tab.id) ?? content.content : ""; return (
diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/types.ts b/apps/desktop/src/renderer/components/files/v2/viewers/types.ts index d3def3d0f..38c02333d 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/types.ts +++ b/apps/desktop/src/renderer/components/files/v2/viewers/types.ts @@ -28,11 +28,11 @@ export type ViewerProps = { /** Shared per-workbench Monaco model cache (code + markdown source use it). */ registry: MonacoModelRegistry; /** Notify the shell that the in-editor buffer became dirty/clean (code viewer only). */ - onDirtyChange?: (path: string, dirty: boolean) => void; + onDirtyChange?: (tabId: string, dirty: boolean) => void; /** Promote a preview tab to permanent on first edit. */ - onEdit?: (path: string) => void; + onEdit?: (tabId: string) => void; /** Register/unregister the editor's imperative API for toolbar actions. */ - onRegisterEditorApi?: (path: string, api: EditorApi | null) => void; + onRegisterEditorApi?: (tabId: string, api: EditorApi | null) => void; /** Surface viewer-owned async failures in the workbench shell. */ onError?: (message: string) => void; }; diff --git a/docs/features/files-and-editor/README.md b/docs/features/files-and-editor/README.md index f23956564..241b7578f 100644 --- a/docs/features/files-and-editor/README.md +++ b/docs/features/files-and-editor/README.md @@ -112,7 +112,8 @@ Renderer: Files tab shell: workspace chrome, activity bar, explorer, editor groups, Monaco edit host, diff/conflict surfaces, quick open, text search, trust warnings, read-only workspace gating, persisted recent-file - pruning, dirty-buffer publishing for agent reads, optional Git-decoration + pruning, project-level open-tab state across lane/workspace switches, + dirty-buffer publishing for agent reads, optional Git-decoration fallback, and file-type viewers. Accepts optional `preferredLaneId` and `embedded` props so the same component can mount inside the Work right-edge sidebar. @@ -128,7 +129,8 @@ Renderer: tree/decorations helpers used by the workbench. - `apps/desktop/src/renderer/components/files/v2/` — VS Code-style workbench shell: editor groups, preview/pinned tabs, split/move - support, warm empty state, search/create overlays, and + support, project-scoped tab-scope persistence, warm empty state, + search/create overlays, and viewers for code, markdown, image, audio/video playback, CSV/TSV, PDF, Office-document fallback, large text, binary, and diffs. - `apps/desktop/src/renderer/components/shared/AdeDiffViewer.tsx` — @@ -196,6 +198,13 @@ user never edits primary when they meant to edit a lane worktree. External tabs also show the full host path in the status bar and path-copy menus so it is clear when a file comes from outside the project. +The v2 workbench keeps one project-level editor session whose tab ids include +both `workspaceId` and path. Switching the explorer workspace changes the tree +being browsed, but it does not close tabs or discard dirty buffers from another +lane/worktree. Selecting an already-open tab from a different workspace moves +the explorer back to that tab's workspace so the tree, mutation controls, and +file actions stay aligned. + ## Editor modes Three modes, each driven by a tab's internal state (no service-side @@ -360,6 +369,9 @@ For deeper detail on the watcher + trust boundary, see - `FilesWorkspace.isReadOnlyByDefault` is enforced in the renderer as well as the service layer: Monaco opens read-only, create / rename / delete controls are disabled, and mutation attempts surface `This workspace is read-only.` +- Workspace switching is navigation, not a discard action. Dirty tabs remain + open and published to the dirty-buffer map under their own workspace root + until the user saves, closes, renames, deletes, or unloads the tab. - File watcher subscriptions are per sender (BrowserWindow / webContents). Closing a window calls `stopAllForSender` to tear down every subscription for that window.