From 512c9b31a8f022d93a9d1cbf3da0c4a21c7e8e28 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Wed, 22 Jul 2026 19:57:18 +0100 Subject: [PATCH] feat: open Changes as a workspace tab (#2298) Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed. Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity. Refs #1520 --- packages/app/e2e/commit-diff-panel.spec.ts | 18 +- packages/app/e2e/diff-row-alignment.spec.ts | 82 +- packages/app/src/agent-stream/view.tsx | 2 +- .../workspace/[workspaceId]/index.tsx | 2 +- .../src/browser-automation/handler.test.ts | 6 +- .../app/src/browser-automation/handler.ts | 7 +- .../app/src/client-slash-commands/index.ts | 2 +- .../compact-explorer-sidebar-host-state.ts | 2 +- .../app/src/components/explorer-sidebar.tsx | 6 +- .../app/src/components/split-container.tsx | 2 +- .../app/src/composer/draft/workspace-tab.tsx | 2 +- .../src/composer/focused-chat-target.test.ts | 2 +- packages/app/src/git/diff-pane.tsx | 949 +++++++++++------- packages/app/src/git/use-diff-query.ts | 17 +- packages/app/src/git/use-working-diff.ts | 169 ++++ packages/app/src/i18n/resources/ar.ts | 4 + packages/app/src/i18n/resources/en.ts | 4 + packages/app/src/i18n/resources/es.ts | 4 + packages/app/src/i18n/resources/fr.ts | 4 + packages/app/src/i18n/resources/ja.ts | 4 + packages/app/src/i18n/resources/pt-BR.ts | 4 + packages/app/src/i18n/resources/ru.ts | 4 + packages/app/src/i18n/resources/zh-CN.ts | 4 + packages/app/src/panels/agent-panel.tsx | 2 +- packages/app/src/panels/commit-diff-panel.tsx | 184 ---- packages/app/src/panels/diff-panel.tsx | 409 ++++++++ packages/app/src/panels/pane-context.tsx | 2 +- packages/app/src/panels/panel-registry.ts | 2 +- packages/app/src/panels/register-panels.ts | 3 +- packages/app/src/panels/setup-panel.tsx | 2 +- .../app/src/screens/new-workspace-screen.tsx | 2 +- .../workspace/visible-agent-ids.test.ts | 2 +- .../screens/workspace/visible-agent-ids.ts | 2 +- .../workspace/workspace-desktop-tabs-row.tsx | 6 +- .../workspace/workspace-file-open-command.ts | 2 +- .../workspace/workspace-pane-state.test.ts | 2 +- .../screens/workspace/workspace-pane-state.ts | 2 +- .../screens/workspace/workspace-screen.tsx | 17 +- .../workspace/workspace-tab-menu.test.ts | 36 +- .../screens/workspace/workspace-tab-menu.ts | 3 + .../workspace/workspace-tab-model.test.ts | 2 +- .../screens/workspace/workspace-tab-model.ts | 2 +- .../screens/workspace/workspace-tabs-types.ts | 2 +- .../navigation.test.ts | 2 +- .../navigation.ts | 2 +- .../workspace-draft-submission-store.ts | 2 +- .../src/stores/workspace-layout-actions.ts | 11 +- .../src/stores/workspace-layout-store.test.ts | 72 +- .../app/src/stores/workspace-layout-store.ts | 7 +- .../app/src/stores/workspace-setup-store.ts | 2 +- .../workspace-subagents-integration.test.ts | 3 +- .../src/stores/workspace-tabs-store/index.ts | 124 --- .../stores/workspace-tabs-store/state.test.ts | 432 -------- .../src/stores/workspace-tabs-store/state.ts | 799 --------------- .../app/src/utils/prepare-workspace-tab.ts | 5 +- .../src/utils/workspace-navigation.test.ts | 2 +- .../app/src/workspace-tabs/identity.test.ts | 36 + packages/app/src/workspace-tabs/identity.ts | 38 +- packages/app/src/workspace-tabs/model.test.ts | 18 + packages/app/src/workspace-tabs/model.ts | 46 + .../src/workspace/use-workspace-archive.ts | 8 +- 61 files changed, 1581 insertions(+), 2012 deletions(-) create mode 100644 packages/app/src/git/use-working-diff.ts delete mode 100644 packages/app/src/panels/commit-diff-panel.tsx create mode 100644 packages/app/src/panels/diff-panel.tsx delete mode 100644 packages/app/src/stores/workspace-tabs-store/index.ts delete mode 100644 packages/app/src/stores/workspace-tabs-store/state.test.ts delete mode 100644 packages/app/src/stores/workspace-tabs-store/state.ts create mode 100644 packages/app/src/workspace-tabs/model.test.ts create mode 100644 packages/app/src/workspace-tabs/model.ts diff --git a/packages/app/e2e/commit-diff-panel.spec.ts b/packages/app/e2e/commit-diff-panel.spec.ts index ca5540400..25d2ed7d2 100644 --- a/packages/app/e2e/commit-diff-panel.spec.ts +++ b/packages/app/e2e/commit-diff-panel.spec.ts @@ -28,26 +28,20 @@ test("commit history shows dates and shares diff layout preferences", async ({ const panel = page.getByTestId("commit-diff-panel").filter({ visible: true }); await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 }); - await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute( - "aria-selected", - "true", - ); + const layoutToggle = panel.getByTestId("commit-diff-toggle-layout"); + await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 }); - await panel.getByTestId("commit-diff-layout-split").click(); - await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( - "aria-selected", - "true", - ); + await layoutToggle.click(); + await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff"); await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); await page.getByTestId(/^workspace-commit-diff-close-/).click(); await expect(panel).toHaveCount(0); await commitRow.click(); - await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( - "aria-selected", - "true", + await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName( + "Switch to unified diff", { timeout: 30_000 }, ); diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/diff-row-alignment.spec.ts index fecb6529f..aff4789a0 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/diff-row-alignment.spec.ts @@ -10,6 +10,7 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; interface DirtyWorkspace { id: string; + repoPath: string; } interface WorkspaceFixtureOptions { @@ -241,6 +242,80 @@ test("changes file actions open from the kebab and right-click", async ({ page } await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); }); +test("Changes switches between inline and full-tab navigation", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const changesTabToggle = page.getByTestId("changes-open-tab"); + await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab"); + await changesTabToggle.click(); + await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab"); + + const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(visiblePanel).toBeVisible(); + await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); + await expect(visiblePanel).toContainText("zz-deleted.ts"); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0); + await visiblePanel.getByTestId("diff-file-0-toggle").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); + await visiblePanel.getByTestId("diff-file-0-toggle").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout"); + await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); + await workingDiffLayoutToggle.click(); + await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff"); + await visiblePanel.getByTestId("working-diff-options-menu").click(); + await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace"); + await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines"); + await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh"); + await page.getByTestId("working-diff-toggle-wrap-lines").click(); + await visiblePanel.getByTestId("working-diff-options-menu").click(); + await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText( + "Scroll long lines", + ); + await page.keyboard.press("Escape"); + await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); + await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); + await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + + await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toHaveCount(0); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); + + await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); + await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + await expect(visiblePanel).toContainText("zz-deleted.ts"); + await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER); + await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText( + "zz-deleted.ts", + ); + await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click(); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); + await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible(); + await expect(visiblePanel).toContainText("Deleted"); + + await changesTabToggle.click(); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toBeVisible(); + await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toHaveCount(0); +}); + test("changes diff switches between flat and tree file lists", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); @@ -266,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await expect(page.getByTestId("diff-folder-src")).toBeVisible(); await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await page.getByRole("button", { name: "Collapse all" }).click(); + await expect(page.getByTestId("diff-file-0")).toHaveCount(0); + await page.getByRole("button", { name: "Expand all" }).click(); + await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); + await page.getByTestId("diff-folder-src-toggle").click(); await expect(page.getByTestId("diff-file-0")).toHaveCount(0); @@ -495,7 +575,7 @@ async function createWorkspaceWithMountedTabDiff( if (!createdWorkspace.workspace) { throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`); } - return { id: createdWorkspace.workspace.id }; + return { id: createdWorkspace.workspace.id, repoPath: repo.path }; } async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c67092029..3ff24bcc8 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -99,7 +99,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; -import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { toErrorMessage } from "@/utils/error-messages"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index f51e1335b..6ecc445e7 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -9,7 +9,7 @@ import { useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { diff --git a/packages/app/src/browser-automation/handler.test.ts b/packages/app/src/browser-automation/handler.test.ts index 6a7d3573a..4af65a17f 100644 --- a/packages/app/src/browser-automation/handler.test.ts +++ b/packages/app/src/browser-automation/handler.test.ts @@ -4,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { mountBrowserAutomationHandler } from "./handler"; import type { DesktopHostBridge } from "@/desktop/host"; import { useBrowserStore } from "@/stores/browser-store"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; type BrowserAutomationExecuteRequest = Extract< SessionOutboundMessage, diff --git a/packages/app/src/browser-automation/handler.ts b/packages/app/src/browser-automation/handler.ts index 6f8ab8ad8..b7cf3d7cd 100644 --- a/packages/app/src/browser-automation/handler.ts +++ b/packages/app/src/browser-automation/handler.ts @@ -6,11 +6,8 @@ import { resizeResidentBrowserWebview, } from "@/components/browser-webview-resident"; import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store"; -import { - buildWorkspaceTabPersistenceKey, - collectAllTabs, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; type BrowserAutomationExecuteRequest = Extract< SessionOutboundMessage, diff --git a/packages/app/src/client-slash-commands/index.ts b/packages/app/src/client-slash-commands/index.ts index 99c4bd202..5d1a31f7d 100644 --- a/packages/app/src/client-slash-commands/index.ts +++ b/packages/app/src/client-slash-commands/index.ts @@ -1,5 +1,5 @@ import type { Agent } from "@/stores/session-store"; -import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft"; export type ClientSlashCommandExecution = "immediate" | "insert"; diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.ts index c0d201623..8d44a9d76 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host-state.ts +++ b/packages/app/src/components/compact-explorer-sidebar-host-state.ts @@ -1,4 +1,4 @@ -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index c233f3582..f4fd089fd 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -36,10 +36,8 @@ import { RetainedPanelActivity } from "@/components/retained-panel"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { useDraftStore } from "@/stores/draft-store"; diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 0d81321d4..59e684968 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -76,7 +76,7 @@ import { type SplitPane, type WorkspaceLayout, } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; import { RenderProfile } from "@/utils/render-profiler"; import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { isNative } from "@/constants/platform"; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d75e9e940..975b51dde 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -50,7 +50,7 @@ import { useIsCompactFormFactor, } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_ONLINE_SERVER_IDS: string[] = []; diff --git a/packages/app/src/composer/focused-chat-target.test.ts b/packages/app/src/composer/focused-chat-target.test.ts index cd8f8014e..a76ae6f64 100644 --- a/packages/app/src/composer/focused-chat-target.test.ts +++ b/packages/app/src/composer/focused-chat-target.test.ts @@ -3,7 +3,7 @@ import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; import { resolveFocusedChatTarget } from "./focused-chat-target"; function layoutWithTarget( - target: import("@/stores/workspace-tabs-store").WorkspaceTab["target"], + target: import("@/workspace-tabs/model").WorkspaceTab["target"], ): WorkspaceLayout { return { root: { diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 2048e7742..5be0ddd55 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -42,18 +42,14 @@ import { List, ListChevronsDownUp, ListChevronsUpDown, + Maximize2, Pilcrow, RefreshCcw, RotateCw, Upload, WrapText, } from "lucide-react-native"; -import { - useCheckoutDiffQuery, - type ParsedDiffFile, - type DiffLine, - type HighlightToken, -} from "@/git/use-diff-query"; +import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; @@ -64,7 +60,6 @@ import { } from "@/components/tree-primitives"; import { SvgXml } from "react-native-svg"; import { getFileIconSvg } from "@/components/material-file-icons"; -import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; @@ -105,8 +100,8 @@ import { useSessionStore } from "@/stores/session-store"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { formatDiffContentText, @@ -116,27 +111,25 @@ import { import { isWeb, isNative } from "@/constants/platform"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; import { - buildWorkspaceAttachmentScopeKey, - useWorkspaceAttachmentsStore, -} from "@/attachments/workspace-attachments-store"; -import { - buildReviewDraftScopeKey, - buildReviewDraftKey, - useReviewAttachmentSnapshot, - useResolvedDiffMode, - useSetDiffModeOverride, type ReviewDraftComment, getInlineReviewThreadState, getSplitInlineReviewThreadState, InlineReviewGutterCell, InlineReviewThread, isInlineReviewEditorForTarget, - useInlineReviewController, type InlineReviewActions, } from "@/review"; +import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; +export function resolveDiffLayout( + layout: "unified" | "split", + canUseSplitLayout: boolean, +): "unified" | "split" { + return canUseSplitLayout ? layout : "unified"; +} + function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { return [styles.fileHeader, pressed && styles.fileHeaderPressed]; } @@ -1110,10 +1103,16 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); } + return ( - {trigger} + + {trigger} + + {file.path} + + ); }); @@ -1330,6 +1329,7 @@ const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp); const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedList = withUnistyles(List); +const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); const ThemedDownload = withUnistyles(Download); const ThemedUpload = withUnistyles(Upload); @@ -1338,7 +1338,6 @@ const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); - const DIFF_OPTIONS_WHITESPACE_ICON = ( ); @@ -1349,11 +1348,22 @@ const DIFF_OPTIONS_WRAP_ICON = ( interface DiffLayoutToggleProps { layout: "unified" | "split"; isMobile: boolean; - toggleStyle: PressableStyleFn; + testID?: string; + toggleStyle?: PressableStyleFn; onToggle: () => void; } -function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) { +export function DiffLayoutToggle({ + layout, + isMobile, + testID = "changes-toggle-layout", + toggleStyle, + onToggle, +}: DiffLayoutToggleProps) { + const defaultToggleStyle = useMemo( + () => buildToggleButtonStyle(false, styles.expandAllButton), + [], + ); const { t } = useTranslation(); const label = layout === "unified" @@ -1365,9 +1375,9 @@ function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayou {layout === "unified" ? ( @@ -1386,6 +1396,98 @@ function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayou ); } +interface ChangesTabToggleProps { + isMobile: boolean; + selected: boolean; + onPress: () => void; +} + +interface DiffModeMenuProps { + diffMode: "uncommitted" | "base"; + committedDescription?: string; + testIDPrefix?: string; + onSelectUncommitted: () => void; + onSelectBase: () => void; +} + +export function DiffModeMenu({ + diffMode, + committedDescription, + testIDPrefix = "changes-diff", + onSelectUncommitted, + onSelectBase, +}: DiffModeMenuProps) { + const { t } = useTranslation(); + const triggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); + const uncommittedLabel = t("workspace.git.diff.uncommitted"); + const committedLabel = t("workspace.git.diff.committed"); + return ( + + + + {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} + + + + + + {uncommittedLabel} + + + + {committedLabel} + + + + ); +} + +function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps) { + const { t } = useTranslation(); + const buttonStyle = useMemo( + () => buildToggleButtonStyle(selected, styles.expandAllButton), + [selected], + ); + const label = t( + selected ? "workspace.git.diff.closeChangesTab" : "workspace.git.diff.openChangesTab", + ); + if (isMobile) { + return null; + } + return ( + + + + + + + + {label} + + + ); +} + interface DiffViewModeToggleProps { viewMode: "flat" | "tree"; isMobile: boolean; @@ -1434,16 +1536,19 @@ function DiffViewModeToggle({ interface DiffFilesToolbarProps { allFileDiffsExpanded: boolean; isMobile: boolean; - expandAllToggleStyle: PressableStyleFn; + testID?: string; + expandAllToggleStyle?: PressableStyleFn; onToggleExpandAll: () => void; } -function DiffFilesToolbar({ +export function DiffFilesToolbar({ allFileDiffsExpanded, isMobile, + testID, expandAllToggleStyle, onToggleExpandAll, }: DiffFilesToolbarProps) { + const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const { t } = useTranslation(); const expandAllLabel = allFileDiffsExpanded ? t("workspace.git.diff.collapseAll") @@ -1455,7 +1560,8 @@ function DiffFilesToolbar({ {allFileDiffsExpanded ? ( @@ -1480,31 +1586,34 @@ function DiffFilesToolbar({ } interface DiffOptionsMenuProps { - brand: string; + brand?: string; hideWhitespace: boolean; isMobile: boolean; - isRefreshing: boolean; - overflowToggleStyle: PressableStyleFn; - refreshSupported: boolean; + isRefreshing?: boolean; + overflowToggleStyle?: PressableStyleFn; + refreshSupported?: boolean; + testIDPrefix?: string; wrapLines: boolean; - onRefresh: () => void; + onRefresh?: () => void; onToggleHideWhitespace: () => void; onToggleWrapLines: () => void; } -function DiffOptionsMenu({ +export function DiffOptionsMenu({ brand, hideWhitespace, isMobile, - isRefreshing, + isRefreshing = false, overflowToggleStyle, - refreshSupported, + refreshSupported = false, + testIDPrefix = "changes", wrapLines, onRefresh, onToggleHideWhitespace, onToggleWrapLines, }: DiffOptionsMenuProps) { const { t } = useTranslation(); + const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const whitespaceLabel = hideWhitespace ? t("workspace.git.diff.showWhitespace") : t("workspace.git.diff.hideWhitespace"); @@ -1512,6 +1621,12 @@ function DiffOptionsMenu({ ? t("workspace.git.diff.scrollLongLines") : t("workspace.git.diff.wrapLongLines"); const optionsLabel = t("workspace.git.diff.options"); + let refreshLabel = t("workspace.git.diff.refresh"); + if (isRefreshing) { + refreshLabel = t("workspace.git.diff.refreshing"); + } else if (brand) { + refreshLabel = t("workspace.git.diff.refreshState", { brand }); + } const refreshIcon = useMemo( () => isRefreshing ? ( @@ -1529,8 +1644,8 @@ function DiffOptionsMenu({ {optionsLabel} - + {whitespaceLabel} @@ -1554,25 +1669,21 @@ function DiffOptionsMenu({ {wrapLinesLabel} - {refreshSupported ? ( + {refreshSupported && onRefresh ? ( <> - {isRefreshing - ? t("workspace.git.diff.refreshing") - : t("workspace.git.diff.refreshState", { - brand, - })} + {refreshLabel} ) : null} @@ -1587,22 +1698,38 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; -function getUnifiedDiffLineCount(file: ParsedDiffFile): number { - let lineCount = 0; - for (const hunk of file.hunks) { - lineCount += hunk.lines.length; - } - return lineCount; +interface DiffFileMetrics { + contentLength: number; + splitLineCount?: number; + unifiedLineCount: number; } -function getDiffContentLength(file: ParsedDiffFile): number { +const diffFileMetricsCache = new WeakMap(); + +function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics { + const cached = diffFileMetricsCache.get(file); + if (cached) { + return cached; + } let contentLength = 0; + let unifiedLineCount = 0; for (const hunk of file.hunks) { + unifiedLineCount += hunk.lines.length; for (const line of hunk.lines) { contentLength += line.content.length; } } - return contentLength; + const metrics = { contentLength, unifiedLineCount }; + diffFileMetricsCache.set(file, metrics); + return metrics; +} + +function getSplitDiffLineCount(file: ParsedDiffFile): number { + const metrics = getDiffFileMetrics(file); + if (metrics.splitLineCount === undefined) { + metrics.splitLineCount = buildSplitDiffRows(file).length; + } + return metrics.splitLineCount; } function computeEmptyMessage( @@ -1710,6 +1837,7 @@ interface SharedDiffViewProps { expandedPaths: string[]; collapsedFolders: string[]; reviewActions?: InlineReviewActions; + onFilePress?: (path: string) => void; workspaceFileDragScope?: { serverId: string; workspaceId: string }; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; @@ -1718,6 +1846,14 @@ interface SharedDiffViewProps { onExpandedPathsChange: (paths: string[]) => void; onCollapsedFoldersChange: (paths: string[]) => void; } + | { + kind: "working_tab"; + expandedPaths: string[] | null; + reviewActions: InlineReviewActions; + focusPath?: string; + focusRequestId?: number; + onExpandedPathsChange: (paths: string[]) => void; + } | { kind: "commit"; }; @@ -1736,17 +1872,25 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi }; }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; - const expandedPathsArray = useMemo( - () => (mode.kind === "working_tree" ? mode.expandedPaths : files.map((file) => file.path)), - [files, mode], - ); + const expandedPathsArray = useMemo(() => { + if (mode.kind === "working_tree") { + return mode.expandedPaths; + } + if (mode.kind === "working_tab" && mode.expandedPaths !== null) { + return mode.expandedPaths; + } + return files.map((file) => file.path); + }, [files, mode]); const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); const collapsedFoldersArray = mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); - const stickyHeaders = mode.kind === "working_tree"; - const interactive = mode.kind === "working_tree"; - const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined; + const stickyHeaders = mode.kind !== "commit"; + const interactive = mode.kind !== "commit"; + const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; + const onFilePress = mode.kind === "working_tree" ? mode.onFilePress : undefined; + const focusPath = mode.kind === "working_tab" ? mode.focusPath : undefined; + const focusRequestId = mode.kind === "working_tab" ? mode.focusRequestId : undefined; const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; const workspaceFileDragScope = @@ -1761,6 +1905,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [allFolderPathSet, collapsedFolders], ); const diffListRef = useRef>(null); + const consumedFocusRequestRef = useRef(null); + const pendingFocusRequestRef = useRef(null); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -1768,6 +1914,24 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const folderRowHeightRef = useRef(0); const defaultHeaderHeightRef = useRef(44); const [heightVersion, setHeightVersion] = useState(0); + const heightVersionFrameRef = useRef(null); + const scheduleHeightVersionUpdate = useCallback(() => { + if (heightVersionFrameRef.current !== null) { + return; + } + heightVersionFrameRef.current = requestAnimationFrame(() => { + heightVersionFrameRef.current = null; + setHeightVersion((version) => version + 1); + }); + }, []); + useEffect( + () => () => { + if (heightVersionFrameRef.current !== null) { + cancelAnimationFrame(heightVersionFrameRef.current); + } + }, + [], + ); const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; @@ -1791,6 +1955,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; } + const metrics = getDiffFileMetrics(file); return [ layout, wrapLines ? "wrap" : "scroll", @@ -1800,8 +1965,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi file.additions, file.deletions, file.hunks.length, - getUnifiedDiffLineCount(file), - getDiffContentLength(file), + metrics.unifiedLineCount, + metrics.contentLength, ].join(":"); }, [layout, typographyKey, wrapLines], @@ -1814,7 +1979,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi } const lineCount = - layout === "split" ? buildSplitDiffRows(file).length : getUnifiedDiffLineCount(file); + layout === "split" + ? getSplitDiffLineCount(file) + : getDiffFileMetrics(file).unifiedLineCount; return diffBodyChromeHeight + lineCount * diffBodyLineHeight; }, [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], @@ -1834,33 +2001,39 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [estimateBodyHeight, getBodyHeightKey], ); - const handleFolderRowHeightChange = useCallback((height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = folderRowHeightRef.current; - if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { - return; - } - folderRowHeightRef.current = height; - setHeightVersion((version) => version + 1); - }, []); + const handleFolderRowHeightChange = useCallback( + (height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + const previousHeight = folderRowHeightRef.current; + if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { + return; + } + folderRowHeightRef.current = height; + scheduleHeightVersionUpdate(); + }, + [scheduleHeightVersionUpdate], + ); - const handleHeaderHeightChange = useCallback((path: string, height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = headerHeightByPathRef.current[path]; - if ( - previousHeight !== undefined && - Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON - ) { - return; - } - headerHeightByPathRef.current[path] = height; - defaultHeaderHeightRef.current = height; - setHeightVersion((version) => version + 1); - }, []); + const handleHeaderHeightChange = useCallback( + (path: string, height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + const previousHeight = headerHeightByPathRef.current[path]; + if ( + previousHeight !== undefined && + Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON + ) { + return; + } + headerHeightByPathRef.current[path] = height; + defaultHeaderHeightRef.current = height; + scheduleHeightVersionUpdate(); + }, + [scheduleHeightVersionUpdate], + ); const handleBodyHeightChange = useCallback( (file: ParsedDiffFile, height: number) => { @@ -1876,9 +2049,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return; } bodyHeightByKeyRef.current[heightKey] = height; - setHeightVersion((version) => version + 1); + scheduleHeightVersionUpdate(); }, - [getBodyHeightKey], + [getBodyHeightKey, scheduleHeightVersionUpdate], ); const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { @@ -1910,9 +2083,43 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [computeItemOffset], ); + useEffect(() => { + if (!focusPath) { + return; + } + const focusRequestKey = `${focusRequestId ?? "initial"}:${focusPath}`; + if ( + consumedFocusRequestRef.current === focusRequestKey || + pendingFocusRequestRef.current === focusRequestKey + ) { + return; + } + const hasTarget = flatItems.some( + (item) => item.type === "header" && item.file.path === focusPath, + ); + if (!hasTarget) { + return; + } + pendingFocusRequestRef.current = focusRequestKey; + const frame = requestAnimationFrame(() => { + diffListRef.current?.scrollToOffset({ + offset: computeHeaderOffset(focusPath), + animated: false, + }); + consumedFocusRequestRef.current = focusRequestKey; + pendingFocusRequestRef.current = null; + }); + return () => { + cancelAnimationFrame(frame); + if (pendingFocusRequestRef.current === focusRequestKey) { + pendingFocusRequestRef.current = null; + } + }; + }, [computeHeaderOffset, flatItems, focusPath, focusRequestId]); + const handleToggleExpanded = useCallback( (path: string) => { - if (mode.kind !== "working_tree") { + if (mode.kind === "commit") { return; } const isCurrentlyExpanded = expandedPaths.has(path); @@ -2004,7 +2211,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi depth={item.depth} showDir={viewMode === "flat"} interactive={interactive} - onToggle={interactive ? handleToggleExpanded : undefined} + onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined} onOpenFile={onOpenFile} onAddToChat={onAddToChat} onCopyPath={onCopyPath} @@ -2041,6 +2248,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi viewMode, wrapLines, interactive, + onFilePress, onOpenFile, onAddToChat, onCopyPath, @@ -2113,53 +2321,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); } -interface DeriveStatusStateInputs { - status: ReturnType["status"]; - isStatusLoading: boolean; - isStatusError: boolean; - statusError: unknown; -} - -interface DerivedStatusState { - gitStatus: NonNullable["status"]> | null; - isGit: boolean; - notGit: boolean; - statusErrorMessage: string | null; - baseRef: string | undefined; - hasUncommittedChanges: boolean; - actionsDisabled: boolean; - currentBranchName: string | null; -} - -function deriveStatusState({ - status, - isStatusLoading, - isStatusError, - statusError, -}: DeriveStatusStateInputs): DerivedStatusState { - const gitStatus = status && status.isGit ? status : null; - const isGit = Boolean(gitStatus); - const notGit = status !== null && !status.isGit && !status.error; - const statusErrorMessage = - status?.error?.message ?? - (isStatusError && statusError instanceof Error ? statusError.message : null); - const baseRef = gitStatus?.baseRef ?? undefined; - const hasUncommittedChanges = Boolean(gitStatus?.isDirty); - const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; - const currentBranchName = - gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; - return { - gitStatus, - isGit, - notGit, - statusErrorMessage, - baseRef, - hasUncommittedChanges, - actionsDisabled, - currentBranchName, - }; -} - function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string { if (!baseRef) return fallbackLabel; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); @@ -2221,11 +2382,14 @@ function parseForgeHost(url: string | null | undefined): string | null { } function buildForgeSetupMessage(input: { - action: Exclude; + action: ForgeSetupAction; forge: Forge; host: string | null; t: TFunction; -}): string { +}): string | null { + if (!input.action) { + return null; + } const { brandLabel, signInCli } = getForgePresentation(input.forge); // A forge with no known CLI (an unknown/third-party forge rendered neutrally) // has no install/sign-in command to interpolate — show neutral guidance @@ -2271,8 +2435,180 @@ function buildToggleButtonStyle( ]; } -function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean { - return input.paneEnabled && input.isGit; +function useChangesTreeState({ + workspaceId, + cwd, + files, + viewMode, + changesTabOpen, + onViewModeChange, +}: { + workspaceId?: string | null; + cwd: string; + files: ParsedDiffFile[]; + viewMode: "flat" | "tree"; + changesTabOpen: boolean; + onViewModeChange: (viewMode: "flat" | "tree") => void; +}) { + const workspaceStateKey = useMemo( + () => + buildWorkspaceExplorerStateKey({ + workspaceId, + workspaceRoot: cwd.trim(), + }), + [cwd, workspaceId], + ); + const expandedPaths = usePanelStore((state) => + workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined, + ); + const collapsedFolders = usePanelStore((state) => + workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined, + ); + const setExpandedPaths = usePanelStore((state) => state.setDiffExpandedPathsForWorkspace); + const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace); + const stableExpandedPaths = expandedPaths ?? EMPTY_PATH_LIST; + const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST; + const folderPaths = useMemo( + () => collectDirPaths(compressSingleChildChains(buildDiffTree(files))), + [files], + ); + const folderPathSet = useMemo(() => new Set(folderPaths), [folderPaths]); + const allExpanded = useMemo(() => { + if (files.length === 0 || changesTabOpen) { + return false; + } + const everyFileExpanded = files.every((file) => stableExpandedPaths.includes(file.path)); + const everyFolderExpanded = + viewMode !== "tree" || + stableCollapsedFolders.every((folderPath) => !folderPathSet.has(folderPath)); + return everyFileExpanded && everyFolderExpanded; + }, [changesTabOpen, files, folderPathSet, stableCollapsedFolders, stableExpandedPaths, viewMode]); + const toggleViewMode = useCallback(() => { + const nextViewMode = viewMode === "flat" ? "tree" : "flat"; + if (nextViewMode === "tree" && workspaceStateKey) { + setCollapsedFolders(workspaceStateKey, []); + } + onViewModeChange(nextViewMode); + }, [onViewModeChange, setCollapsedFolders, viewMode, workspaceStateKey]); + const toggleExpandAll = useCallback(() => { + if (!workspaceStateKey) { + return; + } + if (allExpanded) { + setExpandedPaths(workspaceStateKey, []); + if (viewMode === "tree") { + setCollapsedFolders(workspaceStateKey, folderPaths); + } + return; + } + setExpandedPaths( + workspaceStateKey, + files.map((file) => file.path), + ); + if (viewMode === "tree") { + setCollapsedFolders(workspaceStateKey, []); + } + }, [ + allExpanded, + files, + folderPaths, + setCollapsedFolders, + setExpandedPaths, + viewMode, + workspaceStateKey, + ]); + const updateExpandedPaths = useCallback( + (paths: string[]) => { + if (workspaceStateKey) { + setExpandedPaths(workspaceStateKey, paths); + } + }, + [setExpandedPaths, workspaceStateKey], + ); + const updateCollapsedFolders = useCallback( + (paths: string[]) => { + if (workspaceStateKey) { + setCollapsedFolders(workspaceStateKey, paths); + } + }, + [setCollapsedFolders, workspaceStateKey], + ); + + return { + expandedPaths: changesTabOpen ? EMPTY_PATH_LIST : stableExpandedPaths, + collapsedFolders: stableCollapsedFolders, + allExpanded, + toggleViewMode, + toggleExpandAll, + updateExpandedPaths, + updateCollapsedFolders, + }; +} + +function useDiffTabNavigation({ + serverId, + workspaceId, + cwd, + isMobile, +}: { + serverId: string; + workspaceId?: string | null; + cwd: string; + isMobile: boolean; +}) { + const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); + const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const persistenceKey = useMemo( + () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), + [cwd, serverId, workspaceId], + ); + const changesTabId = useWorkspaceLayoutStore((state) => { + if (!persistenceKey) { + return null; + } + const layout = state.layoutByWorkspace[persistenceKey]; + return ( + layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId + ); + }); + const changesTabOpen = !isMobile && Boolean(changesTabId); + const openChanges = useCallback( + (path?: string) => { + if (!persistenceKey || isMobile) { + return; + } + openWorkspaceTabFocused(persistenceKey, { + kind: "working_diff", + ...(path ? { focusPath: path, focusRequestId: Date.now() } : {}), + }); + }, + [isMobile, openWorkspaceTabFocused, persistenceKey], + ); + const toggleChanges = useCallback(() => { + if (!persistenceKey || isMobile) { + return; + } + if (changesTabId) { + closeWorkspaceTab(persistenceKey, changesTabId); + return; + } + openChanges(); + }, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]); + const openCommit = useCallback( + (sha: string) => { + if (persistenceKey) { + openWorkspaceTabFocused(persistenceKey, { kind: "commit_diff", sha }); + } + }, + [openWorkspaceTabFocused, persistenceKey], + ); + return { + changesTabOpen, + openChanges, + toggleChanges, + openCommit, + onChangesFilePress: changesTabOpen ? openChanges : undefined, + }; } export function GitDiffPane({ @@ -2291,7 +2627,7 @@ export function GitDiffPane({ useChangesPreferences(); const wrapLines = changesPreferences.wrapLines; const viewMode = changesPreferences.viewMode; - const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified"; + const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout); const handleToggleWrapLines = useCallback(() => { void updateChangesPreferences({ wrapLines: !wrapLines }); @@ -2308,8 +2644,6 @@ export function GitDiffPane({ }, [changesPreferences.layout, updateChangesPreferences]); const codeFontSize = appSettings.codeFontSize; - const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); - const layoutToggleStyle = useMemo( () => buildToggleButtonStyle(false, styles.expandAllButton), [], @@ -2325,23 +2659,12 @@ export function GitDiffPane({ const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); - const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); - const commitDiffPersistenceKey = useMemo( - () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), - [cwd, serverId, workspaceId], - ); - const handleCommitPress = useCallback( - (sha: string) => { - if (!commitDiffPersistenceKey) { - return; - } - openWorkspaceTabFocused(commitDiffPersistenceKey, { - kind: "commit_diff", - sha, - }); - }, - [commitDiffPersistenceKey, openWorkspaceTabFocused], - ); + const { + changesTabOpen, + toggleChanges: handleToggleChangesTab, + openCommit: handleCommitPress, + onChangesFilePress, + } = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile }); const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); @@ -2361,106 +2684,34 @@ export function GitDiffPane({ const { status, - isLoading: isStatusLoading, - isError: isStatusError, - error: statusError, - } = useCheckoutStatusQuery({ serverId, cwd }); - const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); - const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges, currentBranchName } = - statusState; - - const reviewDraftScopeKey = useMemo( - () => - buildReviewDraftScopeKey({ - serverId, - workspaceId, - cwd, - baseRef, - ignoreWhitespace: changesPreferences.hideWhitespace, - }), - [baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId], - ); - const diffMode = useResolvedDiffMode({ - scopeKey: reviewDraftScopeKey, - hasUncommittedChanges, - }); - const setDiffModeOverride = useSetDiffModeOverride(); - - const { + isStatusLoading, + isGit, + notGit, + statusErrorMessage, + baseRef, + currentBranchName, + diffMode, + selectUncommitted: handleSelectUncommitted, + selectBase: handleSelectBase, files, - payloadError: diffPayloadError, - isLoading: isDiffLoading, - } = useCheckoutDiffQuery({ - serverId, - cwd, - mode: diffMode, - baseRef, - ignoreWhitespace: changesPreferences.hideWhitespace, - enabled: shouldEnableCheckoutDiff({ paneEnabled: enabled !== false, isGit }), - }); - const reviewDraftKey = useMemo( - () => - buildReviewDraftKey({ - serverId, - workspaceId, - cwd, - mode: diffMode, - baseRef, - ignoreWhitespace: changesPreferences.hideWhitespace, - }), - [baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId], - ); - - const handleSelectUncommitted = useCallback(() => { - setDiffModeOverride({ - scopeKey: reviewDraftScopeKey, - override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges }, - }); - }, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]); - - const handleSelectBase = useCallback(() => { - setDiffModeOverride({ - scopeKey: reviewDraftScopeKey, - override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges }, - }); - }, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]); - - const reviewActions = useInlineReviewController({ - reviewDraftKey, - }); - const reviewAttachment = useReviewAttachmentSnapshot({ - key: reviewDraftKey, - diffFiles: files, - cwd, - mode: diffMode, - baseRef, - }); - const workspaceAttachmentScopeKey = useMemo( - () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), - [cwd, serverId, workspaceId], - ); - const setWorkspaceAttachments = useWorkspaceAttachmentsStore( - (state) => state.setWorkspaceAttachments, - ); - const clearWorkspaceAttachments = useWorkspaceAttachmentsStore( - (state) => state.clearWorkspaceAttachments, - ); - - useEffect(() => { - setWorkspaceAttachments({ - scopeKey: workspaceAttachmentScopeKey, - attachments: reviewAttachment ? [reviewAttachment] : [], - }); - - return () => { - clearWorkspaceAttachments({ scopeKey: workspaceAttachmentScopeKey }); - }; - }, [ - clearWorkspaceAttachments, + diffPayloadError, + isDiffLoading, + reviewActions, reviewAttachment, - setWorkspaceAttachments, - workspaceAttachmentScopeKey, - ]); + } = useWorkingDiff({ + serverId, + workspaceId: workspaceId ?? undefined, + cwd, + ignoreWhitespace: changesPreferences.hideWhitespace, + enabled: enabled !== false, + }); + usePublishWorkingDiffAttachment({ + serverId, + workspaceId: workspaceId ?? undefined, + cwd, + attachment: reviewAttachment, + enabled: !changesTabOpen, + }); const { githubFeaturesEnabled, forge, @@ -2481,42 +2732,28 @@ export function GitDiffPane({ }); const forgeSetupMessage = useMemo( () => - forgeSetupAction - ? buildForgeSetupMessage({ - action: forgeSetupAction, - forge, - host: parseForgeHost(status?.remoteUrl), - t, - }) - : null, + buildForgeSetupMessage({ + action: forgeSetupAction, + forge, + host: parseForgeHost(status?.remoteUrl), + t, + }), [forgeSetupAction, forge, status?.remoteUrl, t], ); - const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]); - const workspaceStateKey = useMemo( - () => - buildWorkspaceExplorerStateKey({ - workspaceId, - workspaceRoot: normalizedWorkspaceRoot, - }), - [normalizedWorkspaceRoot, workspaceId], + const handleViewModeChange = useCallback( + (nextViewMode: "flat" | "tree") => { + void updateChangesPreferences({ viewMode: nextViewMode }); + }, + [updateChangesPreferences], ); - const expandedPathsArray = usePanelStore((state) => - workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined, - ); - const setDiffExpandedPathsForWorkspace = usePanelStore( - (state) => state.setDiffExpandedPathsForWorkspace, - ); - const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]); - // The Changes view groups files into a directory tree on every form factor, - // consistent with the Files explorer (which is also a tree on mobile). - const collapsedFoldersArray = usePanelStore((state) => - workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined, - ); - const setDiffCollapsedFoldersForWorkspace = usePanelStore( - (state) => state.setDiffCollapsedFoldersForWorkspace, - ); - const stableExpandedPathsArray = expandedPathsArray ?? EMPTY_PATH_LIST; - const stableCollapsedFoldersArray = collapsedFoldersArray ?? EMPTY_PATH_LIST; + const changesTree = useChangesTreeState({ + workspaceId, + cwd, + files, + viewMode, + changesTabOpen, + onViewModeChange: handleViewModeChange, + }); const sharedDisplayPreferences = useMemo( () => ({ layout: effectiveLayout, @@ -2526,49 +2763,6 @@ export function GitDiffPane({ }), [appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines], ); - const handleToggleViewMode = useCallback(() => { - const nextViewMode = viewMode === "flat" ? "tree" : "flat"; - if (nextViewMode === "tree" && workspaceStateKey) { - setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []); - } - void updateChangesPreferences({ viewMode: nextViewMode }); - }, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]); - const allFileDiffsExpanded = useMemo(() => { - if (files.length === 0) return false; - return files.every((file) => expandedPaths.has(file.path)); - }, [expandedPaths, files]); - - const handleToggleExpandAll = useCallback(() => { - if (!workspaceStateKey) { - return; - } - if (allFileDiffsExpanded) { - setDiffExpandedPathsForWorkspace(workspaceStateKey, []); - } else { - setDiffExpandedPathsForWorkspace( - workspaceStateKey, - files.map((file) => file.path), - ); - } - }, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); - const handleExpandedPathsChange = useCallback( - (nextPaths: string[]) => { - if (!workspaceStateKey) { - return; - } - setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths); - }, - [setDiffExpandedPathsForWorkspace, workspaceStateKey], - ); - const handleCollapsedFoldersChange = useCallback( - (nextPaths: string[]) => { - if (!workspaceStateKey) { - return; - } - setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextPaths); - }, - [setDiffCollapsedFoldersForWorkspace, workspaceStateKey], - ); const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd }); const handleCopyPath = useCallback( (path: string) => { @@ -2588,30 +2782,32 @@ export function GitDiffPane({ () => ({ kind: "working_tree" as const, viewMode, - expandedPaths: stableExpandedPathsArray, - collapsedFolders: stableCollapsedFoldersArray, + expandedPaths: changesTree.expandedPaths, + collapsedFolders: changesTree.collapsedFolders, reviewActions, + onFilePress: onChangesFilePress, workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, onOpenFile, onAddToChat, onCopyPath: handleCopyPath, onDownload: handleDownloadPath, - onExpandedPathsChange: handleExpandedPathsChange, - onCollapsedFoldersChange: handleCollapsedFoldersChange, + onExpandedPathsChange: changesTree.updateExpandedPaths, + onCollapsedFoldersChange: changesTree.updateCollapsedFolders, }), [ viewMode, - stableExpandedPathsArray, - stableCollapsedFoldersArray, + changesTree.expandedPaths, + changesTree.collapsedFolders, reviewActions, + onChangesFilePress, serverId, workspaceId, onOpenFile, onAddToChat, handleCopyPath, handleDownloadPath, - handleExpandedPathsChange, - handleCollapsedFoldersChange, + changesTree.updateExpandedPaths, + changesTree.updateCollapsedFolders, ], ); @@ -2643,9 +2839,6 @@ export function GitDiffPane({ () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); - const uncommittedLabel = t("workspace.git.diff.uncommitted"); - const committedLabel = t("workspace.git.diff.committed"); - const emptyMessage = computeEmptyMessage( changesPreferences.hideWhitespace, diffMode, @@ -2696,39 +2889,19 @@ export function GitDiffPane({ {isGit ? ( - - - - {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} - - - - - - {uncommittedLabel} - - - - {committedLabel} - - - + - {canUseSplitLayout ? ( + + {canUseSplitLayout && !changesTabOpen ? ( ) : null} - {files.length > 0 ? ( + {files.length > 0 && !changesTabOpen ? ( ) : null} ; @@ -44,6 +45,7 @@ export function useCheckoutDiffQuery({ baseRef, ignoreWhitespace, enabled = true, + queryScope, }: UseCheckoutDiffQueryOptions) { const isConnected = useHostRuntimeIsConnected(serverId); const normalizedCompare = useMemo( @@ -53,10 +55,17 @@ export function useCheckoutDiffQuery({ const compareMode = normalizedCompare.mode; const compareBaseRef = normalizedCompare.baseRef; const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace; - const queryKey = useMemo( - () => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace), - [serverId, cwd, mode, baseRef, compareIgnoreWhitespace], - ); + const queryKey = useMemo(() => { + const comparisonKey = checkoutDiffQueryKey( + serverId, + cwd, + compareMode, + compareBaseRef, + compareIgnoreWhitespace, + ); + const normalizedScope = queryScope?.trim(); + return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; + }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); const routeEnabled = Boolean(enabled && isConnected && cwd); diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts new file mode 100644 index 000000000..55e8709bd --- /dev/null +++ b/packages/app/src/git/use-working-diff.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { + buildWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import { + buildReviewDraftKey, + buildReviewDraftScopeKey, + useInlineReviewController, + useResolvedDiffMode, + useReviewAttachmentSnapshot, + useSetDiffModeOverride, +} from "@/review"; +import { useCheckoutDiffQuery } from "@/git/use-diff-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; + +interface UseWorkingDiffOptions { + serverId: string; + workspaceId?: string; + cwd: string; + ignoreWhitespace: boolean; + enabled: boolean; + queryScope?: string; +} + +export function useWorkingDiff({ + serverId, + workspaceId, + cwd, + ignoreWhitespace, + enabled, + queryScope, +}: UseWorkingDiffOptions) { + const { + status, + isLoading: isStatusLoading, + isError: isStatusError, + error: statusError, + } = useCheckoutStatusQuery({ serverId, cwd }); + const gitStatus = status && status.isGit ? status : null; + const isGit = Boolean(gitStatus); + const notGit = status !== null && !status.isGit && !status.error; + const statusErrorMessage = + status?.error?.message ?? + (isStatusError && statusError instanceof Error ? statusError.message : null); + const baseRef = gitStatus?.baseRef ?? undefined; + const hasUncommittedChanges = Boolean(gitStatus?.isDirty); + const currentBranchName = + gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; + + const reviewDraftScopeKey = useMemo( + () => + buildReviewDraftScopeKey({ + serverId, + workspaceId, + cwd, + baseRef, + ignoreWhitespace, + }), + [baseRef, cwd, ignoreWhitespace, serverId, workspaceId], + ); + const diffMode = useResolvedDiffMode({ + scopeKey: reviewDraftScopeKey, + hasUncommittedChanges, + }); + const setDiffModeOverride = useSetDiffModeOverride(); + const selectDiffMode = useCallback( + (mode: "uncommitted" | "base") => { + setDiffModeOverride({ + scopeKey: reviewDraftScopeKey, + override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges }, + }); + }, + [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride], + ); + const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]); + const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]); + + const { + files, + payloadError: diffPayloadError, + isLoading: isDiffLoading, + } = useCheckoutDiffQuery({ + serverId, + cwd, + mode: diffMode, + baseRef, + ignoreWhitespace, + enabled: enabled && isGit, + queryScope, + }); + const reviewDraftKey = useMemo( + () => + buildReviewDraftKey({ + serverId, + workspaceId, + cwd, + mode: diffMode, + baseRef, + ignoreWhitespace, + }), + [baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId], + ); + const reviewActions = useInlineReviewController({ reviewDraftKey }); + const reviewAttachment = useReviewAttachmentSnapshot({ + key: reviewDraftKey, + diffFiles: files, + cwd, + mode: diffMode, + baseRef, + }); + + return { + status, + isStatusLoading, + isGit, + notGit, + statusErrorMessage, + baseRef, + currentBranchName, + diffMode, + selectUncommitted, + selectBase, + files, + diffPayloadError, + isDiffLoading, + reviewActions, + reviewAttachment, + }; +} + +export function usePublishWorkingDiffAttachment({ + serverId, + workspaceId, + cwd, + attachment, + enabled, +}: { + serverId: string; + workspaceId?: string; + cwd: string; + attachment: ReturnType["reviewAttachment"]; + enabled: boolean; +}) { + const scopeKey = useMemo( + () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), + [cwd, serverId, workspaceId], + ); + const setWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.setWorkspaceAttachments, + ); + const clearWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.clearWorkspaceAttachments, + ); + + useEffect(() => { + if (!enabled) { + return; + } + const attachments = attachment ? [attachment] : []; + setWorkspaceAttachments({ scopeKey, attachments }); + return () => { + const current = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]; + if (current === attachments) { + clearWorkspaceAttachments({ scopeKey }); + } + }; + }, [attachment, clearWorkspaceAttachments, enabled, scopeKey, setWorkspaceAttachments]); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 87dd6f583..6f6518733 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -758,6 +758,8 @@ export const ar: TranslationResources = { }, }, diff: { + openChangesTab: "فتح علامة تبويب التغييرات", + closeChangesTab: "إغلاق علامة تبويب التغييرات", binaryFile: "ملف ثنائي", tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", unified: "الفرق الموحدة", @@ -1494,6 +1496,8 @@ export const ar: TranslationResources = { changesLabel: "التغييرات", changesSubtitle: "فروقات شجرة العمل", commitSubtitle: "فروقات الالتزام", + uncommittedSubtitle: "تغييرات غير ملتزم بها", + baseSubtitle: "مقارنة مع {{baseRef}}", directoryMissing: "لم يتم العثور على دليل Workspace.", empty: "لا توجد تغييرات", loadError: "فشل تحميل الفروقات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index db5921e04..bc8bba546 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -768,6 +768,8 @@ export const en = { }, }, diff: { + openChangesTab: "Open Changes tab", + closeChangesTab: "Close Changes tab", binaryFile: "Binary file", tooLarge: "Diff too large to display", unified: "Unified diff", @@ -1505,6 +1507,8 @@ export const en = { changesLabel: "Changes", changesSubtitle: "Working tree diff", commitSubtitle: "Commit diff", + uncommittedSubtitle: "Uncommitted changes", + baseSubtitle: "Compared with {{baseRef}}", directoryMissing: "Workspace directory not found.", empty: "No changes", loadError: "Failed to load diff", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 4edc7bce9..204273c96 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -789,6 +789,8 @@ export const es: TranslationResources = { }, }, diff: { + openChangesTab: "Abrir la pestaña Cambios", + closeChangesTab: "Cerrar la pestaña Cambios", binaryFile: "archivo binario", tooLarge: "La diferencia es demasiado grande para mostrarse", unified: "Diferencia unificada", @@ -1537,6 +1539,8 @@ export const es: TranslationResources = { changesLabel: "Cambios", changesSubtitle: "Diferencias del árbol de trabajo", commitSubtitle: "Diferencias del commit", + uncommittedSubtitle: "Cambios sin confirmar", + baseSubtitle: "Comparado con {{baseRef}}", directoryMissing: "No se encontró el directorio de Workspace.", empty: "Sin cambios", loadError: "No se pudieron cargar las diferencias", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2c7e67bd0..07dd95a2a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -788,6 +788,8 @@ export const fr: TranslationResources = { }, }, diff: { + openChangesTab: "Ouvrir l'onglet Modifications", + closeChangesTab: "Fermer l'onglet Modifications", binaryFile: "Fichier binaire", tooLarge: "Diff trop grand pour être affiché", unified: "Différentiel unifié", @@ -1540,6 +1542,8 @@ export const fr: TranslationResources = { changesLabel: "Modifications", changesSubtitle: "Différences de l'arbre de travail", commitSubtitle: "Différences du commit", + uncommittedSubtitle: "Modifications non validées", + baseSubtitle: "Comparé à {{baseRef}}", directoryMissing: "Répertoire Workspace introuvable.", empty: "Aucune modification", loadError: "Échec du chargement des différences", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 2029d9da0..5a27447dc 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -769,6 +769,8 @@ export const ja: TranslationResources = { }, }, diff: { + openChangesTab: "変更タブを開く", + closeChangesTab: "変更タブを閉じる", binaryFile: "バイナリファイル", tooLarge: "差分が大きすぎて表示できません", unified: "ユニファイド差分", @@ -1510,6 +1512,8 @@ export const ja: TranslationResources = { changesLabel: "変更", changesSubtitle: "作業ツリーの差分", commitSubtitle: "コミット差分", + uncommittedSubtitle: "未コミットの変更", + baseSubtitle: "{{baseRef}} との比較", directoryMissing: "ワークスペースディレクトリが見つかりません。", empty: "変更はありません", loadError: "差分の読み込みに失敗しました", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 61030df17..2fce56ddd 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -780,6 +780,8 @@ export const ptBR: TranslationResources = { }, }, diff: { + openChangesTab: "Abrir a aba Alterações", + closeChangesTab: "Fechar a aba Alterações", binaryFile: "Arquivo binário", tooLarge: "Diff grande demais para exibir", unified: "Diff unificado", @@ -1523,6 +1525,8 @@ export const ptBR: TranslationResources = { changesLabel: "Alterações", changesSubtitle: "Diff da árvore de trabalho", commitSubtitle: "Diff do commit", + uncommittedSubtitle: "Alterações não commitadas", + baseSubtitle: "Comparado com {{baseRef}}", directoryMissing: "Diretório do workspace não encontrado.", empty: "Nenhuma alteração", loadError: "Falha ao carregar diff", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 179ef7025..51e83e28d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -780,6 +780,8 @@ export const ru: TranslationResources = { }, }, diff: { + openChangesTab: "Открыть вкладку «Изменения»", + closeChangesTab: "Закрыть вкладку «Изменения»", binaryFile: "Бинарный файл", tooLarge: "Разница слишком велика для отображения", unified: "Единый дифференциал", @@ -1528,6 +1530,8 @@ export const ru: TranslationResources = { changesLabel: "Изменения", changesSubtitle: "Различия рабочего дерева", commitSubtitle: "Различия коммита", + uncommittedSubtitle: "Незафиксированные изменения", + baseSubtitle: "Сравнение с {{baseRef}}", directoryMissing: "Каталог Workspace не найден.", empty: "Нет изменений", loadError: "Не удалось загрузить различия", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index fbb97630f..b9ddd3cc7 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -750,6 +750,8 @@ export const zhCN: TranslationResources = { }, }, diff: { + openChangesTab: "打开“更改”标签页", + closeChangesTab: "关闭“更改”标签页", binaryFile: "二进制文件", tooLarge: "Diff 过大,无法显示", unified: "Unified diff", @@ -1475,6 +1477,8 @@ export const zhCN: TranslationResources = { changesLabel: "更改", changesSubtitle: "工作区差异", commitSubtitle: "提交差异", + uncommittedSubtitle: "未提交的更改", + baseSubtitle: "与 {{baseRef}} 比较", directoryMissing: "未找到 workspace 目录。", empty: "没有更改", loadError: "加载差异失败", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index e34c6aa4f..2c3dab2d8 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -73,7 +73,7 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; import { useHideFinishedProviderSubagents, diff --git a/packages/app/src/panels/commit-diff-panel.tsx b/packages/app/src/panels/commit-diff-panel.tsx deleted file mode 100644 index 8cd5ef679..000000000 --- a/packages/app/src/panels/commit-diff-panel.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useCallback, useMemo, type ReactNode } from "react"; -import { Text, View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { GitCommitHorizontal } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import invariant from "tiny-invariant"; -import { SegmentedControl } from "@/components/ui/segmented-control"; -import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { isWeb } from "@/constants/platform"; -import { useChangesPreferences } from "@/hooks/use-changes-preferences"; -import { useAppSettings } from "@/hooks/use-settings"; -import { SharedDiffView } from "@/git/diff-pane"; -import { useCommitDiffFiles } from "@/git/use-diff-files"; -import { usePaneContext } from "@/panels/pane-context"; -import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; - -const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); - -function CommitDiffPanel() { - const { t } = useTranslation(); - const { serverId, workspaceId, target } = usePaneContext(); - const cwd = useWorkspaceDirectory(serverId, workspaceId); - const { settings } = useAppSettings(); - const { preferences, updatePreferences } = useChangesPreferences(); - const isCompact = useIsCompactFormFactor(); - invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); - - const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ - serverId, - cwd: cwd ?? "", - sha: target.sha, - enabled: Boolean(cwd), - }); - const canUseSplitLayout = isWeb && !isCompact; - const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified"; - const layoutOptions = useMemo( - () => [ - { - value: "unified" as const, - label: t("workspace.git.diff.unified"), - testID: "commit-diff-layout-unified", - }, - { - value: "split" as const, - label: t("workspace.git.diff.split"), - testID: "commit-diff-layout-split", - }, - ], - [t], - ); - const handleLayoutChange = useCallback( - (layout: "unified" | "split") => { - void updatePreferences({ layout }); - }, - [updatePreferences], - ); - const displayPreferences = useMemo( - () => ({ - layout: effectiveLayout, - wrapLines: preferences.wrapLines, - codeFontSize: settings.codeFontSize, - monoFontFamily: settings.monoFontFamily, - }), - [effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily], - ); - const commitMode = useMemo(() => ({ kind: "commit" as const }), []); - - if (!cwd) { - return ( - - {t("panels.diff.directoryMissing")} - - ); - } - - if (capabilityMissing) { - return ( - - {t("panels.diff.capabilityMissing")} - - ); - } - let bodyContent: ReactNode; - if (error) { - bodyContent = ( - - {t("panels.diff.loadError")} - - ); - } else if (isLoading && files.length === 0) { - bodyContent = ( - - {t("workspace.tabs.loading")} - - ); - } else if (files.length === 0) { - bodyContent = ( - - {t("panels.diff.empty")} - - ); - } else { - bodyContent = ( - - ); - } - - return ( - - {canUseSplitLayout ? ( - - - - ) : null} - {bodyContent} - - ); -} - -function useCommitDiffPanelDescriptor( - target: Extract, -): PanelDescriptor { - const { t } = useTranslation(); - return { - label: target.sha.slice(0, 7), - subtitle: t("panels.diff.commitSubtitle"), - tooltip: target.sha, - titleState: "ready", - icon: ThemedGitCommitHorizontal, - statusBucket: null, - }; -} - -export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = { - kind: "commit_diff", - component: CommitDiffPanel, - useDescriptor: useCommitDiffPanelDescriptor, -}; - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - minHeight: 0, - }, - toolbar: { - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-end", - paddingHorizontal: theme.spacing[3], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - flexShrink: 0, - }, - body: { - flex: 1, - minHeight: 0, - }, - centerState: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[6], - paddingTop: theme.spacing[16], - }, - mutedText: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - errorText: { - fontSize: theme.fontSize.base, - color: theme.colors.destructive, - textAlign: "center", - }, -})); diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx new file mode 100644 index 000000000..65cc1b4c2 --- /dev/null +++ b/packages/app/src/panels/diff-panel.tsx @@ -0,0 +1,409 @@ +import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { FileDiff, GitCommitHorizontal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { useToast } from "@/contexts/toast-context"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { + DiffFilesToolbar, + DiffLayoutToggle, + DiffModeMenu, + DiffOptionsMenu, + resolveDiffLayout, + SharedDiffView, +} from "@/git/diff-pane"; +import { useCommitDiffFiles } from "@/git/use-diff-files"; +import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; +import { useChangesPreferences } from "@/hooks/use-changes-preferences"; +import { useAppSettings } from "@/hooks/use-settings"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; + +const ThemedFileDiff = withUnistyles(FileDiff); +const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); + +function useDiffPanelPreferences() { + const { settings } = useAppSettings(); + const { preferences, updatePreferences } = useChangesPreferences(); + const isCompact = useIsCompactFormFactor(); + const canUseSplitLayout = isWeb && !isCompact; + const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout); + const displayPreferences = useMemo( + () => ({ + layout: effectiveLayout, + wrapLines: preferences.wrapLines, + codeFontSize: settings.codeFontSize, + monoFontFamily: settings.monoFontFamily, + }), + [effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily], + ); + const toggleLayout = useCallback(() => { + void updatePreferences({ layout: preferences.layout === "unified" ? "split" : "unified" }); + }, [preferences.layout, updatePreferences]); + const toggleWrapLines = useCallback(() => { + void updatePreferences({ wrapLines: !preferences.wrapLines }); + }, [preferences.wrapLines, updatePreferences]); + const toggleHideWhitespace = useCallback(() => { + void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); + }, [preferences.hideWhitespace, updatePreferences]); + + return { + preferences, + isCompact, + canUseSplitLayout, + displayPreferences, + toggleLayout, + toggleWrapLines, + toggleHideWhitespace, + }; +} + +function PanelState({ + message, + tone = "muted", + testID, +}: { + message: string; + tone?: "muted" | "error"; + testID?: string; +}) { + return ( + + {message} + + ); +} + +function WorkingDiffBody({ + cwd, + isConnected, + workingDiff, + hideWhitespace, + displayPreferences, + mode, +}: { + cwd: string | null | undefined; + isConnected: boolean; + workingDiff: ReturnType; + hideWhitespace: boolean; + displayPreferences: ReturnType["displayPreferences"]; + mode: Extract["mode"], { kind: "working_tab" }>; +}) { + const { t } = useTranslation(); + if (!cwd) { + return ; + } + if (!isConnected) { + return ; + } + if (workingDiff.isStatusLoading) { + return ; + } + if (workingDiff.statusErrorMessage) { + return ( + + ); + } + if (workingDiff.notGit) { + return ; + } + if (workingDiff.diffPayloadError) { + return ( + + ); + } + if (workingDiff.isDiffLoading && workingDiff.files.length === 0) { + return ; + } + if (workingDiff.files.length === 0) { + return ( + + ); + } + return ( + + ); +} + +function WorkingDiffPanel() { + const { t } = useTranslation(); + const toast = useToast(); + const { serverId, workspaceId, tabId, target } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const isConnected = useHostRuntimeIsConnected(serverId); + const isActive = useRetainedPanelActive(); + const panelPreferences = useDiffPanelPreferences(); + const [expandedPaths, setExpandedPaths] = useState(null); + invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target"); + + const workingDiff = useWorkingDiff({ + serverId, + workspaceId, + cwd: cwd ?? "", + ignoreWhitespace: panelPreferences.preferences.hideWhitespace, + enabled: Boolean(cwd) && isActive, + queryScope: `working-diff-tab:${tabId}`, + }); + usePublishWorkingDiffAttachment({ + serverId, + workspaceId, + cwd: cwd ?? "", + attachment: workingDiff.reviewAttachment, + enabled: Boolean(cwd) && isActive, + }); + + const refreshSupported = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, + ); + const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); + const isRefreshing = + useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }), + ) === "pending"; + const refresh = useCallback(() => { + if (!cwd || isRefreshing) { + return; + } + void runRefresh({ serverId, cwd }).catch((error) => { + toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); + }); + }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + + const expandedPathSet = useMemo( + () => (expandedPaths === null ? null : new Set(expandedPaths)), + [expandedPaths], + ); + const allFilesExpanded = + workingDiff.files.length > 0 && + (expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path))); + const toggleExpandAll = useCallback(() => { + setExpandedPaths(allFilesExpanded ? [] : null); + }, [allFilesExpanded]); + const mode = useMemo( + () => ({ + kind: "working_tab" as const, + expandedPaths, + reviewActions: workingDiff.reviewActions, + focusPath: target.focusPath, + focusRequestId: target.focusRequestId, + onExpandedPathsChange: setExpandedPaths, + }), + [expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions], + ); + + const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? ""; + return ( + + + + + {panelPreferences.canUseSplitLayout ? ( + + ) : null} + {workingDiff.files.length > 0 ? ( + + ) : null} + + + + + + + + ); +} + +function CommitDiffPanel() { + const { t } = useTranslation(); + const { serverId, workspaceId, target } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const panelPreferences = useDiffPanelPreferences(); + invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); + const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ + serverId, + cwd: cwd ?? "", + sha: target.sha, + enabled: Boolean(cwd), + }); + const mode = useMemo(() => ({ kind: "commit" as const }), []); + + let body: ReactNode; + if (!cwd) { + body = ; + } else if (capabilityMissing) { + body = ( + + ); + } else if (error) { + body = ( + + ); + } else if (isLoading && files.length === 0) { + body = ; + } else if (files.length === 0) { + body = ; + } else { + body = ( + + ); + } + + return ( + + {panelPreferences.canUseSplitLayout ? ( + + + + + + ) : null} + {body} + + ); +} + +function useWorkingDiffPanelDescriptor(): PanelDescriptor { + const { t } = useTranslation(); + return { + label: t("panels.diff.changesLabel"), + subtitle: t("panels.diff.changesSubtitle"), + tooltip: t("panels.diff.changesSubtitle"), + titleState: "ready", + icon: ThemedFileDiff, + statusBucket: null, + }; +} + +function useCommitDiffPanelDescriptor( + target: Extract, +): PanelDescriptor { + const { t } = useTranslation(); + return { + label: target.sha.slice(0, 7), + subtitle: t("panels.diff.commitSubtitle"), + tooltip: target.sha, + titleState: "ready", + icon: ThemedGitCommitHorizontal, + statusBucket: null, + }; +} + +export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = { + kind: "working_diff", + component: WorkingDiffPanel, + useDescriptor: useWorkingDiffPanelDescriptor, +}; + +export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = { + kind: "commit_diff", + component: CommitDiffPanel, + useDescriptor: useCommitDiffPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + }, + toolbar: { + height: WORKSPACE_SECONDARY_HEADER_HEIGHT, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + flexShrink: 0, + }, + toolbarActions: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, + body: { + flex: 1, + minHeight: 0, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[16], + }, + mutedText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + errorText: { + fontSize: theme.fontSize.base, + color: theme.colors.destructive, + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index b49ca43b4..b45449f29 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, type ReactNode } from "react"; import invariant from "tiny-invariant"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface PaneContextValue { diff --git a/packages/app/src/panels/panel-registry.ts b/packages/app/src/panels/panel-registry.ts index 5fa16e5aa..6c8afb14f 100644 --- a/packages/app/src/panels/panel-registry.ts +++ b/packages/app/src/panels/panel-registry.ts @@ -1,5 +1,5 @@ import type { ComponentType } from "react"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; export interface PanelIconProps { diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index c86c656e3..9b8d37069 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -1,6 +1,6 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { browserPanelRegistration } from "@/panels/browser-panel"; -import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel"; +import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; @@ -22,5 +22,6 @@ export function ensurePanelsRegistered(): void { registerPanel(browserPanelRegistration); registerPanel(filePanelRegistration); registerPanel(commitDiffPanelRegistration); + registerPanel(workingDiffPanelRegistration); panelsRegistered = true; } diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 3ca919a60..a183b8255 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -13,7 +13,7 @@ import invariant from "tiny-invariant"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import type { Theme } from "@/styles/theme"; import { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index e60adaf2e..e8863d0d2 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { getWorkspaceNamingAttachments, diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index 3fbe56119..662088947 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; import { selectVisibleAgentIds } from "./visible-agent-ids"; test("selects only the active agent tab in every visible pane", () => { diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index cfaf4b3eb..b9e9e8662 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -1,5 +1,5 @@ import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; import { deriveWorkspacePaneState } from "./workspace-pane-state"; export function selectVisibleAgentIds(input: { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 620e14fcf..5689f318d 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -444,7 +444,7 @@ interface WorkspaceDesktopTabsRowProps { function getFallbackTabLabel( tab: WorkspaceTabDescriptor, - labels: { newAgent: string; setup: string; terminal: string; agent: string }, + labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string }, ): string { if (tab.target.kind === "draft") { return labels.newAgent; @@ -458,6 +458,9 @@ function getFallbackTabLabel( if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } + if (tab.target.kind === "working_diff") { + return labels.changes; + } return labels.agent; } @@ -836,6 +839,7 @@ export function WorkspaceDesktopTabsRow({ setup: t("workspace.tabs.fallback.setup"), terminal: t("workspace.tabs.fallback.terminal"), agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), }), [t], ); diff --git a/packages/app/src/screens/workspace/workspace-file-open-command.ts b/packages/app/src/screens/workspace/workspace-file-open-command.ts index 0ec7cc8ac..e6af1348b 100644 --- a/packages/app/src/screens/workspace/workspace-file-open-command.ts +++ b/packages/app/src/screens/workspace/workspace-file-open-command.ts @@ -2,7 +2,7 @@ import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, } from "@/workspace/file-open"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; interface OpenWorkspaceFileFromExplorerInput { filePath: string; diff --git a/packages/app/src/screens/workspace/workspace-pane-state.test.ts b/packages/app/src/screens/workspace/workspace-pane-state.test.ts index d57e4ffac..778d8fded 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.test.ts @@ -5,7 +5,7 @@ import { resolveSideFileOpenPlacement, } from "@/screens/workspace/workspace-pane-state"; import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab { return { diff --git a/packages/app/src/screens/workspace/workspace-pane-state.ts b/packages/app/src/screens/workspace/workspace-pane-state.ts index e32a05722..016e2f0c8 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.ts @@ -3,7 +3,7 @@ import { type SplitPane, type WorkspaceLayout, } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { buildDeterministicWorkspaceTabId, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f41bde08a..f64447d9a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -74,14 +74,17 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { - buildWorkspaceTabPersistenceKey, collectAllTabs, getFocusedBrowserId, type WorkspaceLayout, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceTab, + type WorkspaceTabTarget, +} from "@/workspace-tabs/model"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -345,6 +348,7 @@ function getFallbackTabOptionLabel( terminal: string; browser: string; agent: string; + changes: string; }, ): string { if (tab.target.kind === "draft") { @@ -362,6 +366,9 @@ function getFallbackTabOptionLabel( if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } + if (tab.target.kind === "working_diff") { + return labels.changes; + } if (tab.target.kind === "commit_diff") { return tab.target.sha.slice(0, 7); } @@ -376,6 +383,7 @@ function getFallbackTabOptionDescription( agent: string; terminal: string; browser: string; + changes: string; }, ): string { if (tab.target.kind === "draft") { @@ -399,6 +407,9 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "commit_diff") { return tab.target.sha.slice(0, 7); } + if (tab.target.kind === "working_diff") { + return labels.changes; + } return tab.target.path; } @@ -671,6 +682,7 @@ function MobileWorkspaceTabOption({ terminal: t("workspace.tabs.fallback.terminal"), browser: t("workspace.tabs.fallback.browser"), agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), }), [t], ); @@ -2514,6 +2526,7 @@ function WorkspaceScreenContent({ terminal: t("workspace.tabs.fallback.terminal"), browser: t("workspace.tabs.fallback.browser"), agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), }), [t], ); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 68325ee74..56de7207c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; +import { + buildWorkspaceDesktopTabActions, + buildWorkspaceTabMenuEntries, +} from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; function createAgentTab(): WorkspaceTabDescriptor { @@ -253,6 +256,37 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts"); }); + it("uses a Changes close id for the working diff tab", () => { + const actions = buildWorkspaceDesktopTabActions({ + tab: { + key: "working_diff_abc", + tabId: "working_diff_abc", + kind: "working_diff", + target: { + kind: "working_diff", + focusPath: "src/example.ts", + focusRequestId: 1, + }, + }, + index: 0, + tabCount: 1, + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsToLeft: vi.fn(), + onCloseTabsToRight: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + expect(actions.closeButtonTestId).toMatch(/^workspace-working-diff-close-/); + expect(actions.menuEntries).not.toContainEqual( + expect.objectContaining({ kind: "item", key: "copy-file-path" }), + ); + }); + it("uses the same rename entry shape for agent and terminal tabs", () => { const terminalTab: WorkspaceTabDescriptor = { key: "terminal_abc", diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index db15ea577..4845b3160 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -144,6 +144,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "commit_diff") { return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`; } + if (tab.target.kind === "working_diff") { + return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 5bfa56660..a01bc34e5 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; describe("deriveWorkspaceTabModel", () => { it("keeps normalized tabs in stored order and preserves targets", () => { diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 6a5c8acfa..b7e6c967f 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -1,4 +1,4 @@ -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { deriveWorkspacePaneState, type WorkspaceDerivedTab, diff --git a/packages/app/src/screens/workspace/workspace-tabs-types.ts b/packages/app/src/screens/workspace/workspace-tabs-types.ts index ef1a09e21..4a41512fa 100644 --- a/packages/app/src/screens/workspace/workspace-tabs-types.ts +++ b/packages/app/src/screens/workspace/workspace-tabs-types.ts @@ -1,4 +1,4 @@ -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; export interface WorkspaceTabDescriptor { key: string; diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index a45d1f8ea..998796c62 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { navigateToLastWorkspace, navigateToWorkspace, diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index 24e92b74a..393653c62 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -11,7 +11,7 @@ import { resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-identity"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab"; export interface RouteSelectionInput { diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index d31a14485..2dbd27f2a 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface PendingWorkspaceDraftSubmission { serverId: string; diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index f7633b34c..5ccf90adc 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1,5 +1,5 @@ import invariant from "tiny-invariant"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants"; import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids"; import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids"; @@ -285,9 +285,14 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null { const tab = value as WorkspaceTab; const target = normalizeWorkspaceTabTarget(tab.target); + if (!target) { + return null; + } const tabId = - trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null); - if (!target || !tabId) { + target.kind === "working_diff" + ? buildDeterministicWorkspaceTabId(target) + : (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target)); + if (!tabId) { return null; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index b65eedd2e..24d162eae 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -15,9 +15,8 @@ vi.mock("@react-native-async-storage/async-storage", () => { }; }); -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { - buildWorkspaceTabPersistenceKey, collectAllPanes, collectAllTabs, createWorkspaceLayoutStore, @@ -27,8 +26,10 @@ import { getFocusedBrowserId, getTreeDepth, insertSplit, + normalizeLayout, removePaneFromTree, removeTabFromTree, + stripEphemeralTabsFromLayout, type SplitNode, type SplitPane, } from "@/stores/workspace-layout-store"; @@ -1128,6 +1129,73 @@ describe("workspace-layout-store actions", () => { ]); }); + it("persists working diff tabs while stripping commit diff tabs", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { + kind: "working_diff", + focusPath: "src/a.ts", + }); + store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" }); + + const partialize = workspaceLayoutStore.persist.getOptions().partialize; + expect(partialize).toBeTypeOf("function"); + if (!partialize) { + throw new Error("Workspace layout partialize function is missing"); + } + const currentState = workspaceLayoutStore.getState(); + const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]); + const persisted = partialize(currentState); + + expect(persisted).toEqual({ + layoutByWorkspace: { [workspaceKey]: layout }, + splitSizesByWorkspace: currentState.splitSizesByWorkspace, + }); + expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ + { + kind: "working_diff", + focusPath: "src/a.ts", + }, + ]); + }); + + it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => { + const legacyTabId = "working_diff_uncommitted_0_n"; + const layout = normalizeLayout({ + root: { + kind: "pane", + pane: { + id: "main", + tabIds: [legacyTabId], + focusedTabId: legacyTabId, + tabs: [ + { + tabId: legacyTabId, + createdAt: 1, + target: { + kind: "working_diff", + focusPath: "src/a.ts", + mode: "uncommitted", + baseRef: null, + ignoreWhitespace: false, + }, + }, + ], + }, + }, + focusedPaneId: "main", + }); + + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: "working_diff", + target: { kind: "working_diff", focusPath: "src/a.ts" }, + createdAt: 1, + }, + ]); + }); + it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { useWorkspaceLayoutIds( "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 2f84c53b8..dee6c5869 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -2,11 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import { - buildWorkspaceTabPersistenceKey, - type WorkspaceTab, - type WorkspaceTabTarget, -} from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { defaultWorkspaceLayoutIds, type WorkspaceLayoutIdSource, @@ -47,7 +43,6 @@ import { } from "@/stores/workspace-layout-actions"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; -export { buildWorkspaceTabPersistenceKey }; export { collectAllPanes, collectAllTabs, diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 23e69eedc..669d4b646 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,6 +1,6 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; export type WorkspaceCreationMethod = "open_project" | "create_worktree"; diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index d3838dad8..1559359f0 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -5,8 +5,9 @@ import { deriveWorkspaceAgentVisibility, type WorkspaceAgentVisibility, } from "@/workspace-tabs/agent-visibility"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { selectSubagentsForParent } from "@/subagents/select"; -import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; +import { useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; vi.mock("@react-native-async-storage/async-storage", () => { diff --git a/packages/app/src/stores/workspace-tabs-store/index.ts b/packages/app/src/stores/workspace-tabs-store/index.ts deleted file mode 100644 index 013f3fca4..000000000 --- a/packages/app/src/stores/workspace-tabs-store/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import { - applyCloseTab, - applyEnsureTab, - applyFocusTab, - applyOpenDraftTab, - applyOpenOrFocusTab, - applyPurgeWorkspace, - applyReorderTabs, - applyRetargetTab, - initialWorkspaceTabsCoreState, - migrateWorkspaceTabsState, - partializeWorkspaceTabsState, - selectWorkspaceTabs, - type WorkspaceTab, - type WorkspaceTabsCoreState, - type WorkspaceTabTarget, -} from "./state"; - -export { buildWorkspaceTabPersistenceKey } from "./state"; -export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state"; - -interface WorkspaceTabsState extends WorkspaceTabsCoreState { - openDraftTab: (input: { - serverId: string; - workspaceId: string; - draftId: string; - }) => string | null; - ensureTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - openOrFocusTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - retargetTab: (input: { - serverId: string; - workspaceId: string; - tabId: string; - target: WorkspaceTabTarget; - }) => string | null; - reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; - getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; - purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void; -} - -export const useWorkspaceTabsStore = create()( - persist( - (set, get) => ({ - ...initialWorkspaceTabsCoreState, - openDraftTab: ({ serverId, workspaceId, draftId }) => { - let resolved: string | null = null; - set((state) => { - const result = applyOpenDraftTab(state, { - serverId, - workspaceId, - draftId, - now: Date.now(), - }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - ensureTab: ({ serverId, workspaceId, target }) => { - let resolved: string | null = null; - set((state) => { - const result = applyEnsureTab(state, { - serverId, - workspaceId, - target, - now: Date.now(), - }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - openOrFocusTab: ({ serverId, workspaceId, target }) => { - let resolved: string | null = null; - set((state) => { - const result = applyOpenOrFocusTab(state, { - serverId, - workspaceId, - target, - now: Date.now(), - }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - focusTab: (input) => set((state) => applyFocusTab(state, input)), - closeTab: (input) => set((state) => applyCloseTab(state, input)), - retargetTab: ({ serverId, workspaceId, tabId, target }) => { - let resolved: string | null = null; - set((state) => { - const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - reorderTabs: (input) => set((state) => applyReorderTabs(state, input)), - getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input), - purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)), - }), - { - name: "workspace-tabs-state", - version: 5, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }), - migrate: (persistedState) => - migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState, - }, - ), -); diff --git a/packages/app/src/stores/workspace-tabs-store/state.test.ts b/packages/app/src/stores/workspace-tabs-store/state.test.ts deleted file mode 100644 index a8bde4245..000000000 --- a/packages/app/src/stores/workspace-tabs-store/state.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - applyCloseTab, - applyEnsureTab, - applyFocusTab, - applyOpenDraftTab, - applyOpenOrFocusTab, - applyRetargetTab, - buildWorkspaceTabPersistenceKey, - initialWorkspaceTabsCoreState, - migrateWorkspaceTabsState, - type WorkspaceTabsCoreState, -} from "./state"; - -const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/worktree"; -const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`; - -const NOW = 1_700_000_000_000; - -function emptyState(): WorkspaceTabsCoreState { - return { - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, - }; -} - -describe("buildWorkspaceTabPersistenceKey", () => { - it("preserves opaque workspace ids instead of normalizing them like paths", () => { - expect( - buildWorkspaceTabPersistenceKey({ - serverId: SERVER_ID, - workspaceId: " setup\\workspace\\ ", - }), - ).toBe("server-1:setup\\workspace\\"); - }); -}); - -describe("workspace-tabs-store reducers", () => { - it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { - const draftTabId = "draft_123"; - - let state = emptyState(); - state = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "left" }, - now: NOW, - }).state; - state = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - now: NOW, - }).state; - state = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "right" }, - now: NOW, - }).state; - state = applyFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - }); - - const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; - - const retargeted = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created" }, - }); - - const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; - const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; - const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; - - expect(retargeted.tabId).toBe(draftTabId); - expect(afterOrder).toEqual(beforeOrder); - expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId); - expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); - }); - - it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { - let state = emptyState(); - const ensured = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - now: NOW, - }); - state = ensured.state; - expect(ensured.tabId).toBe("terminal_term-1"); - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - - const focused = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - now: NOW, - }); - expect(focused.tabId).toBe("terminal_term-1"); - expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1"); - }); - - it("ensureTab deduplicates by target when a retargeted tab already exists", () => { - const draftTabId = "draft_x"; - - let state = emptyState(); - state = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - now: NOW, - }).state; - state = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }).state; - - const ensured = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "created-agent" }, - now: NOW, - }); - - const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; - const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; - const matchingTabs = tabs.filter( - (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent", - ); - - expect(ensured.tabId).toBe(draftTabId); - expect(matchingTabs).toHaveLength(1); - expect(order).toEqual([draftTabId]); - }); - - it("openDraftTab creates a draft tab and deduplicates by draftId", () => { - let state = emptyState(); - const first = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-1", - now: NOW, - }); - state = first.state; - const second = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-2", - now: NOW, - }); - state = second.state; - - expect(first.tabId).toBe("draft-1"); - expect(second.tabId).toBe("draft-2"); - expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([ - { - tabId: "draft-1", - target: { kind: "draft", draftId: "draft-1" }, - createdAt: NOW, - }, - { - tabId: "draft-2", - target: { kind: "draft", draftId: "draft-2" }, - createdAt: NOW, - }, - ]); - }); - - it("keeps draft setup on a retargeted tab", () => { - let state = emptyState(); - const ensured = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "agent-1" }, - now: NOW, - }); - expect(ensured.tabId).toBe("agent_agent-1"); - state = ensured.state; - - const retargeted = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: ensured.tabId!, - target: { - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }, - }); - - expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }); - }); - - it("updates an existing draft tab when the setup changes", () => { - let state = emptyState(); - const first = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "draft", draftId: "draft-1" }, - now: NOW, - }); - state = first.state; - const second = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }, - now: NOW, - }); - state = second.state; - - expect(second.tabId).toBe(first.tabId); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }); - }); - - it("retargeting a background draft keeps the currently focused tab focused", () => { - const draftTabId = "draft_background"; - - let state = emptyState(); - state = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - now: NOW, - }).state; - const file = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - now: NOW, - }); - state = file.state; - - state = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }).state; - - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId); - }); - - it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => { - let state = emptyState(); - const fileResult = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - now: NOW, - }); - state = fileResult.state; - const terminalResult = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - now: NOW, - }); - state = terminalResult.state; - - expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts"); - expect(terminalResult.tabId).toBe("terminal_term-1"); - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId); - - const reopened = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - now: NOW, - }); - - expect(reopened.tabId).toBe(fileResult.tabId); - expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId); - }); - - it("builds a deterministic setup tab keyed by workspace id", () => { - const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "setup", workspaceId: WORKSPACE_ID }, - now: NOW, - }); - - expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`); - expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId); - }); - - it("opens a commit diff tab with a commit-specific id", () => { - let state = emptyState(); - const commit = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "commit_diff", sha: "abc123" }, - now: NOW, - }); - state = commit.state; - - expect(commit.tabId).toBe("commit_diff_abc123"); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); - }); - - it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => { - let state = emptyState(); - const first = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "left" }, - now: NOW, - }); - state = first.state; - const second = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "right" }, - now: NOW, - }); - state = second.state; - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId); - - state = applyCloseTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: second.tabId!, - }); - - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); - }); -}); - -describe("migrateWorkspaceTabsState commit diff coercion", () => { - // This legacy store no longer enforces the "commit diff tabs are ephemeral" - // guarantee — that now lives in the workspace-layout store's partialize (see - // stripEphemeralTabsFromLayout). This migration only needs to carry old commit - // diff targets forward to the dedicated `commit_diff` tab shape. - it("migrates a legacy commit diff tab to the dedicated target shape", () => { - const persisted = { - state: { - uiTabsByWorkspace: { - [WORKSPACE_KEY]: [ - { - tabId: "commit_diff_abc123", - target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } }, - createdAt: NOW, - }, - ], - }, - }, - }; - - const migrated = migrateWorkspaceTabsState(persisted, { now: NOW }); - const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; - - expect(tabs).toHaveLength(1); - expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" }); - expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]); - }); - - it("drops a legacy working diff tab during migration", () => { - const persisted = { - state: { - uiTabsByWorkspace: { - [WORKSPACE_KEY]: [ - { - tabId: "diff_working:base:main", - target: { - kind: "diff", - diffTarget: { kind: "working", mode: "base", baseRef: "main" }, - }, - createdAt: NOW, - }, - ], - }, - }, - }; - - const migrated = migrateWorkspaceTabsState(persisted, { now: NOW }); - - expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - }); -}); diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts deleted file mode 100644 index 9a670b271..000000000 --- a/packages/app/src/stores/workspace-tabs-store/state.ts +++ /dev/null @@ -1,799 +0,0 @@ -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { - buildDeterministicWorkspaceTabId, - normalizeWorkspaceDraftTabSetup, - normalizeWorkspaceTabTarget, - workspaceTabTargetsEqual, -} from "@/workspace-tabs/identity"; -import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; - -export interface WorkspaceDraftTabSetup { - provider: AgentProvider; - cwd: string; - modeId: string | null; - model: string | null; - thinkingOptionId: string | null; - featureValues: Record; -} - -export type WorkspaceTabTarget = - | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } - | { kind: "agent"; agentId: string } - | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } - | { kind: "terminal"; terminalId: string } - | { kind: "browser"; browserId: string } - | WorkspaceFileTabTarget - | { kind: "setup"; workspaceId: string } - | { kind: "commit_diff"; sha: string }; - -export interface WorkspaceTab { - tabId: string; - target: WorkspaceTabTarget; - createdAt: number; -} - -export interface WorkspaceTabsCoreState { - uiTabsByWorkspace: Record; - tabOrderByWorkspace: Record; - focusedTabIdByWorkspace: Record; -} - -export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = { - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, -}; - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function buildWorkspaceTabPersistenceKey(input: { - serverId: string; - workspaceId: string; -}): string | null { - const serverId = trimNonEmpty(input.serverId); - const workspaceId = trimNonEmpty(input.workspaceId); - if (!serverId || !workspaceId) { - return null; - } - // workspaceId is opaque; do not parse this key back into a path. - return `${serverId}:${workspaceId}`; -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function toObjectRecord(value: unknown): Record | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeTabOrder(list: unknown): string[] { - if (!Array.isArray(list)) { - return []; - } - const next: string[] = []; - const used = new Set(); - for (const value of list) { - const tabId = trimNonEmpty(typeof value === "string" ? value : null); - if (!tabId || used.has(tabId)) { - continue; - } - used.add(tabId); - next.push(tabId); - } - return next; -} - -function ensureInOrder(input: { current: string[]; tabId: string }): string[] { - if (input.current.includes(input.tabId)) { - return input.current; - } - return [...input.current, input.tabId]; -} - -function retargetTabAtIndex( - tab: WorkspaceTab, - index: number, - targetIndex: number, - normalizedTarget: WorkspaceTabTarget, -): WorkspaceTab { - return index === targetIndex ? { ...tab, target: normalizedTarget } : tab; -} - -function buildNextTabsForEnsure(args: { - currentTabs: WorkspaceTab[]; - existingIndex: number; - effectiveTabId: string; - normalizedTarget: WorkspaceTabTarget; - createdAt: number; -}): WorkspaceTab[] { - const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args; - if (existingIndex < 0) { - return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }]; - } - const existing = currentTabs[existingIndex]; - if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { - return currentTabs; - } - return currentTabs.map((tab, index) => - retargetTabAtIndex(tab, index, existingIndex, normalizedTarget), - ); -} - -export interface EnsureTabInput { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - now: number; -} - -export interface EnsureTabResult { - state: WorkspaceTabsCoreState; - tabId: string | null; -} - -export function applyEnsureTab( - state: WorkspaceTabsCoreState, - input: EnsureTabInput, -): EnsureTabResult { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTarget = normalizeWorkspaceTabTarget(input.target); - if (!key || !normalizedTarget) { - return { state, tabId: null }; - } - - const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const tabWithSameTarget = - currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null; - const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; - - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); - const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); - const nextTabs = buildNextTabsForEnsure({ - currentTabs, - existingIndex, - effectiveTabId, - normalizedTarget, - createdAt: input.now, - }); - - const uiTabsByWorkspace = - nextTabs === currentTabs - ? state.uiTabsByWorkspace - : { ...state.uiTabsByWorkspace, [key]: nextTabs }; - const tabOrderByWorkspace = - nextOrder === currentOrder - ? state.tabOrderByWorkspace - : { ...state.tabOrderByWorkspace, [key]: nextOrder }; - - if ( - uiTabsByWorkspace === state.uiTabsByWorkspace && - tabOrderByWorkspace === state.tabOrderByWorkspace - ) { - return { state, tabId: effectiveTabId }; - } - - return { - state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace }, - tabId: effectiveTabId, - }; -} - -export interface FocusTabInput { - serverId: string; - workspaceId: string; - tabId: string; -} - -export function applyFocusTab( - state: WorkspaceTabsCoreState, - input: FocusTabInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTabId = trimNonEmpty(input.tabId); - if (!key || !normalizedTabId) { - return state; - } - if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { - return state; - } - return { - ...state, - focusedTabIdByWorkspace: { - ...state.focusedTabIdByWorkspace, - [key]: normalizedTabId, - }, - }; -} - -export function applyOpenOrFocusTab( - state: WorkspaceTabsCoreState, - input: EnsureTabInput, -): EnsureTabResult { - const ensured = applyEnsureTab(state, input); - if (!ensured.tabId) { - return ensured; - } - const focused = applyFocusTab(ensured.state, { - serverId: input.serverId, - workspaceId: input.workspaceId, - tabId: ensured.tabId, - }); - return { state: focused, tabId: ensured.tabId }; -} - -export interface OpenDraftTabInput { - serverId: string; - workspaceId: string; - draftId: string; - now: number; -} - -export function applyOpenDraftTab( - state: WorkspaceTabsCoreState, - input: OpenDraftTabInput, -): EnsureTabResult { - const normalizedDraftId = trimNonEmpty(input.draftId); - if (!normalizedDraftId) { - return { state, tabId: null }; - } - return applyOpenOrFocusTab(state, { - serverId: input.serverId, - workspaceId: input.workspaceId, - target: { kind: "draft", draftId: normalizedDraftId }, - now: input.now, - }); -} - -export interface CloseTabInput { - serverId: string; - workspaceId: string; - tabId: string; -} - -export function applyCloseTab( - state: WorkspaceTabsCoreState, - input: CloseTabInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTabId = trimNonEmpty(input.tabId); - if (!key || !normalizedTabId) { - return state; - } - - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); - - let nextUiTabsByWorkspace: Record; - if (nextTabs.length === 0) { - const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; - nextUiTabsByWorkspace = rest; - } else if (nextTabs.length === currentTabs.length) { - nextUiTabsByWorkspace = state.uiTabsByWorkspace; - } else { - nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs }; - } - - let nextTabOrderByWorkspace: Record; - if (nextOrder.length === 0) { - const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; - nextTabOrderByWorkspace = rest; - } else if (nextOrder.length === currentOrder.length) { - nextTabOrderByWorkspace = state.tabOrderByWorkspace; - } else { - nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder }; - } - - const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; - const nextFocused = - currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null); - const nextFocusedByWorkspace = (() => { - if (!nextFocused) { - const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; - return rest; - } - return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; - })(); - - const tabsChanged = nextTabs.length !== currentTabs.length; - const orderChanged = nextOrder.length !== currentOrder.length; - const focusChanged = - (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); - - if (!tabsChanged && !orderChanged && !focusChanged) { - return state; - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedByWorkspace, - }; -} - -export interface RetargetTabInput { - serverId: string; - workspaceId: string; - tabId: string; - target: WorkspaceTabTarget; -} - -export function applyRetargetTab( - state: WorkspaceTabsCoreState, - input: RetargetTabInput, -): EnsureTabResult { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTabId = trimNonEmpty(input.tabId); - const normalizedTarget = normalizeWorkspaceTabTarget(input.target); - if (!key || !normalizedTabId || !normalizedTarget) { - return { state, tabId: null }; - } - - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); - if (index < 0) { - return { state, tabId: null }; - } - - const currentTarget = currentTabs[index]?.target; - if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { - return { state, tabId: null }; - } - - const nextTabs = currentTabs.map((tab, tabIndex) => - tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab, - ); - - return { - state: { - ...state, - uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, - }, - tabId: normalizedTabId, - }; -} - -export interface ReorderTabsInput { - serverId: string; - workspaceId: string; - tabIds: string[]; -} - -export function applyReorderTabs( - state: WorkspaceTabsCoreState, - input: ReorderTabsInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - if (!key) { - return state; - } - - const normalized = normalizeTabOrder(input.tabIds); - const current = state.tabOrderByWorkspace[key] ?? []; - if (current.length === normalized.length) { - let same = true; - for (let i = 0; i < current.length; i += 1) { - if (current[i] !== normalized[i]) { - same = false; - break; - } - } - if (same) { - return state; - } - } - - return { - ...state, - tabOrderByWorkspace: { - ...state.tabOrderByWorkspace, - [key]: normalized, - }, - }; -} - -export interface PurgeWorkspaceInput { - serverId: string; - workspaceId: string; -} - -export function applyPurgeWorkspace( - state: WorkspaceTabsCoreState, - input: PurgeWorkspaceInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - if (!key) { - return state; - } - if ( - !(key in state.uiTabsByWorkspace) && - !(key in state.tabOrderByWorkspace) && - !(key in state.focusedTabIdByWorkspace) - ) { - return state; - } - const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace; - const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace; - const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace; - return { - ...state, - uiTabsByWorkspace: remainingUiTabsByWorkspace, - tabOrderByWorkspace: remainingTabOrderByWorkspace, - focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace, - }; -} - -export function selectWorkspaceTabs( - state: WorkspaceTabsCoreState, - input: { serverId: string; workspaceId: string }, -): WorkspaceTab[] { - const key = buildWorkspaceTabPersistenceKey(input); - if (!key) { - return []; - } - return state.uiTabsByWorkspace[key] ?? []; -} - -interface MigrationRawSources { - rawUiTabsByWorkspace: Record; - rawFocused: Record; - rawOrder: Record; - legacyOrder: Record; -} - -function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { - const top = toObjectRecord(persistedState) ?? {}; - const rawState = toObjectRecord(top.state) ?? top; - - return { - rawUiTabsByWorkspace: - toObjectRecord( - rawState.uiTabsByWorkspace ?? - rawState.openTabsByWorkspace ?? - top.uiTabsByWorkspace ?? - top.openTabsByWorkspace, - ) ?? {}, - rawFocused: - toObjectRecord( - rawState.focusedTabIdByWorkspace ?? - rawState.lastFocusedTabByWorkspace ?? - top.focusedTabIdByWorkspace, - ) ?? {}, - rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, - legacyOrder: - toObjectRecord( - rawState.tabOrderByWorkspace ?? - rawState.tabOrderLegacyByWorkspace ?? - top.tabOrderLegacyByWorkspace, - ) ?? {}, - }; -} - -function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { - const kind = typeof raw.kind === "string" ? raw.kind : null; - if (kind === "draft" && typeof raw.draftId === "string") { - const setup = normalizeWorkspaceDraftTabSetup(raw.setup); - return normalizeWorkspaceTabTarget({ - kind: "draft", - draftId: raw.draftId, - ...(setup ? { setup } : {}), - }); - } - if (kind === "agent" && typeof raw.agentId === "string") { - return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); - } - if ( - kind === "provider_subagent" && - typeof raw.parentAgentId === "string" && - typeof raw.subagentId === "string" - ) { - return normalizeWorkspaceTabTarget({ - kind: "provider_subagent", - parentAgentId: raw.parentAgentId, - subagentId: raw.subagentId, - }); - } - if (kind === "terminal" && typeof raw.terminalId === "string") { - return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); - } - if (kind === "browser" && typeof raw.browserId === "string") { - return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); - } - if (kind === "file" && typeof raw.path === "string") { - return normalizeWorkspaceTabTarget({ - kind: "file", - path: raw.path, - lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, - lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, - }); - } - if (kind === "setup" && typeof raw.workspaceId === "string") { - return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); - } - return coercePersistedDiffTabTargetByKind(kind, raw); -} - -function coercePersistedDiffTabTargetByKind( - kind: string | null, - raw: Record, -): WorkspaceTabTarget | null { - if (kind === "commit_diff" && typeof raw.sha === "string") { - return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha }); - } - return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null; -} - -// NOTE: This legacy store no longer persists diff tabs — live tab persistence is -// owned by the workspace-layout store, which is where the "commit diff tabs are -// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout). -// This coercion exists only so this store's migration stays type-complete for any -// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff -// tabs are dropped because they no longer exist as workspace targets; commit diff -// tabs migrate to the dedicated `commit_diff` target shape. -function coercePersistedDiffTabTarget(raw: Record): WorkspaceTabTarget | null { - const diffTarget = toObjectRecord(raw.diffTarget); - if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") { - return null; - } - return normalizeWorkspaceTabTarget({ - kind: "commit_diff", - sha: diffTarget.sha, - }); -} - -function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null { - const record = toObjectRecord(rawTab); - if (!record) { - return null; - } - const rawTarget = toObjectRecord(record.target); - const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; - if (!normalizedTarget) { - return null; - } - const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); - const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); - const rawCreatedAt = record.createdAt; - return { - tabId, - target: normalizedTarget, - createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now, - }; -} - -interface MigratedTabsForKey { - nextUiTabs: WorkspaceTab[]; - orderFromTabs: string[]; -} - -function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey { - const entries = Array.isArray(rawEntries) ? rawEntries : []; - const nextUiTabs: WorkspaceTab[] = []; - const orderFromTabs: string[] = []; - const usedOrder = new Set(); - - for (const rawTab of entries) { - const migrated = migrateSingleTab(rawTab, now); - if (!migrated) { - continue; - } - if (!usedOrder.has(migrated.tabId)) { - usedOrder.add(migrated.tabId); - orderFromTabs.push(migrated.tabId); - } - nextUiTabs.push(migrated); - } - - return { nextUiTabs, orderFromTabs }; -} - -function mergeExplicitTabOrder( - tabOrderByWorkspace: Record, - rawOrder: Record, -): void { - for (const key in rawOrder) { - const normalizedOrder = normalizeTabOrder(rawOrder[key]); - if (normalizedOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); - } -} - -function convertLegacyOrderEntry(entry: unknown): string | null { - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - return null; - } - if (raw.startsWith("agent:")) { - const agentId = raw.slice("agent:".length).trim(); - return agentId ? `agent_${agentId}` : null; - } - if (raw.startsWith("terminal:")) { - const terminalId = raw.slice("terminal:".length).trim(); - return terminalId ? `terminal_${terminalId}` : null; - } - return null; -} - -function normalizeLegacyOrderList(list: unknown[]): string[] { - const result: string[] = []; - for (const entry of list) { - const converted = convertLegacyOrderEntry(entry); - if (converted) { - result.push(converted); - } - } - return result; -} - -function mergeLegacyTabOrder( - tabOrderByWorkspace: Record, - legacyOrder: Record, -): void { - for (const key in legacyOrder) { - const list = legacyOrder[key]; - if (!Array.isArray(list) || list.length === 0) { - continue; - } - const normalizedLegacyOrder = normalizeLegacyOrderList(list); - if (normalizedLegacyOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); - } -} - -function resolveFocusedTabId(rawValue: unknown): string | null { - if (typeof rawValue === "string") { - return trimNonEmpty(rawValue); - } - if (!rawValue || typeof rawValue !== "object") { - return null; - } - const value = rawValue as { - kind?: string; - agentId?: string; - terminalId?: string; - draftId?: string; - }; - if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { - return `agent_${value.agentId.trim()}`; - } - if ( - value.kind === "terminal" && - typeof value.terminalId === "string" && - value.terminalId.trim() - ) { - return `terminal_${value.terminalId.trim()}`; - } - if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { - return value.draftId.trim(); - } - return null; -} - -function migrateFocusedTabIds( - focusedTabIdByWorkspace: Record, - rawFocused: Record, -): void { - for (const key in rawFocused) { - const resolved = resolveFocusedTabId(rawFocused[key]); - if (resolved) { - focusedTabIdByWorkspace[key] = resolved; - } - } -} - -export function migrateWorkspaceTabsState( - persistedState: unknown, - options: { now: number }, -): WorkspaceTabsCoreState { - const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } = - extractMigrationRawSources(persistedState); - - const uiTabsByWorkspace: Record = {}; - const tabOrderByWorkspace: Record = {}; - const focusedTabIdByWorkspace: Record = {}; - - for (const key in rawUiTabsByWorkspace) { - const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey( - rawUiTabsByWorkspace[key], - options.now, - ); - if (nextUiTabs.length > 0) { - uiTabsByWorkspace[key] = nextUiTabs; - } - if (orderFromTabs.length > 0) { - tabOrderByWorkspace[key] = orderFromTabs; - } - } - - mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder); - mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder); - migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused); - - return { - uiTabsByWorkspace, - tabOrderByWorkspace, - focusedTabIdByWorkspace, - }; -} - -export function partializeWorkspaceTabsState( - state: WorkspaceTabsCoreState, - options: { now: number }, -): WorkspaceTabsCoreState { - const nextUiTabsByWorkspace: Record = {}; - for (const key in state.uiTabsByWorkspace) { - const tabs = (state.uiTabsByWorkspace[key] ?? []) - .map((tab) => { - const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); - const normalizedTabId = trimNonEmpty(tab.tabId); - if (!normalizedTarget || !normalizedTabId) { - return null; - } - return { - tabId: normalizedTabId, - target: normalizedTarget, - createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now, - } satisfies WorkspaceTab; - }) - .filter((tab): tab is WorkspaceTab => tab !== null); - if (tabs.length > 0) { - nextUiTabsByWorkspace[key] = tabs; - } - } - - const nextTabOrderByWorkspace: Record = {}; - for (const key in state.tabOrderByWorkspace) { - const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); - if (order.length > 0) { - nextTabOrderByWorkspace[key] = order; - } - } - - const nextFocusedTabIdByWorkspace: Record = {}; - for (const key in state.focusedTabIdByWorkspace) { - const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); - if (focusedTabId) { - nextFocusedTabIdByWorkspace[key] = focusedTabId; - } - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, - }; -} diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts index 213b107f1..7c8d7a43a 100644 --- a/packages/app/src/utils/prepare-workspace-tab.ts +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -1,8 +1,5 @@ import { generateDraftId } from "@/stores/draft-keys"; -import { - buildWorkspaceTabPersistenceKey, - type WorkspaceTabTarget, -} from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; export interface PrepareWorkspaceTabInput { serverId: string; diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 0f96b5f22..f4a7b7a9a 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; const SERVER_ID = "server-1"; diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index 60e85293b..5c175f4f3 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -44,6 +44,42 @@ describe("provider subagent tab identity", () => { }); }); +describe("working diff tab identity", () => { + const target = { + kind: "working_diff" as const, + focusPath: "src/example.ts", + focusRequestId: 1, + }; + + it("normalizes file focus navigation", () => { + expect( + normalizeWorkspaceTabTarget({ + ...target, + focusPath: " src\\example.ts ", + }), + ).toEqual(target); + }); + + it("treats focus as navigation state rather than tab identity", () => { + expect(workspaceTabTargetsEqual(target, target)).toBe(true); + expect(workspaceTabTargetsEqual(target, { ...target, focusPath: "src/other.ts" })).toBe(false); + expect(workspaceTabTargetsEqual(target, { ...target, focusRequestId: 2 })).toBe(false); + const workingDiffId = buildDeterministicWorkspaceTabId(target); + const otherFocusId = buildDeterministicWorkspaceTabId({ + ...target, + focusPath: "src/other.ts", + }); + const fileId = buildDeterministicWorkspaceTabId({ + kind: "file", + path: target.focusPath, + }); + + expect(workingDiffId).toBe("working_diff"); + expect(workingDiffId).toBe(otherFocusId); + expect(workingDiffId).not.toBe(fileId); + }); +}); + describe("commit diff tab identity", () => { it("keys a commit diff tab by its sha", () => { expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe( diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 73bfec022..0741321c7 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -1,7 +1,5 @@ -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open"; - -type WorkspaceDraftTabSetup = NonNullable["setup"]>; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; export function normalizeWorkspaceTabTarget( value: WorkspaceTabTarget | null | undefined, @@ -31,6 +29,9 @@ export function normalizeWorkspaceTabTarget( if (value.kind === "file") { return normalizeFileTabTarget(value); } + if (value.kind === "working_diff") { + return normalizeWorkingDiffTabTarget(value); + } return normalizeSimpleWorkspaceTabTarget(value); } @@ -104,12 +105,22 @@ export function workspaceTabTargetsEqual( if (left.kind === "terminal" && right.kind === "terminal") { return left.terminalId === right.terminalId; } + return secondaryWorkspaceTabTargetsEqual(left, right); +} + +function secondaryWorkspaceTabTargetsEqual( + left: WorkspaceTabTarget, + right: WorkspaceTabTarget, +): boolean { if (left.kind === "browser" && right.kind === "browser") { return left.browserId === right.browserId; } if (left.kind === "file" && right.kind === "file") { return workspaceFileLocationsEqual(left, right); } + if (left.kind === "working_diff" && right.kind === "working_diff") { + return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId; + } if (left.kind === "setup" && right.kind === "setup") { return left.workspaceId === right.workspaceId; } @@ -174,6 +185,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "commit_diff") { return `commit_diff_${target.sha}`; } + if (target.kind === "working_diff") { + return "working_diff"; + } return `file_${target.path}`; } @@ -192,6 +206,24 @@ function normalizeFileTabTarget( return location ? { kind: "file", ...location } : null; } +function normalizeWorkingDiffTabTarget( + value: Extract, +): WorkspaceTabTarget | null { + const focusPath = trimNonEmpty(value.focusPath)?.replace(/\\/g, "/") ?? null; + const focusRequestId = normalizePositiveInteger(value.focusRequestId); + return { + kind: "working_diff" as const, + ...(focusPath ? { focusPath } : {}), + ...(focusRequestId ? { focusRequestId } : {}), + }; +} + +function normalizePositiveInteger(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : null; +} + function trimOptionalString(value: string | null | undefined): string | null { return value == null ? null : trimNonEmpty(value); } diff --git a/packages/app/src/workspace-tabs/model.test.ts b/packages/app/src/workspace-tabs/model.test.ts new file mode 100644 index 000000000..c9623bbd9 --- /dev/null +++ b/packages/app/src/workspace-tabs/model.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildWorkspaceTabPersistenceKey } from "./model"; + +describe("buildWorkspaceTabPersistenceKey", () => { + it("trims and joins opaque server and workspace ids", () => { + expect( + buildWorkspaceTabPersistenceKey({ + serverId: " server-1 ", + workspaceId: " setup\\workspace\\ ", + }), + ).toBe("server-1:setup\\workspace\\"); + }); + + it("rejects incomplete identities", () => { + expect(buildWorkspaceTabPersistenceKey({ serverId: "", workspaceId: "workspace" })).toBeNull(); + expect(buildWorkspaceTabPersistenceKey({ serverId: "server", workspaceId: " " })).toBeNull(); + }); +}); diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts new file mode 100644 index 000000000..885894475 --- /dev/null +++ b/packages/app/src/workspace-tabs/model.ts @@ -0,0 +1,46 @@ +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; + +export interface WorkspaceDraftTabSetup { + provider: AgentProvider; + cwd: string; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record; +} + +export interface WorkspaceWorkingDiffTabTarget { + kind: "working_diff"; + focusPath?: string; + focusRequestId?: number; +} + +export type WorkspaceTabTarget = + | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } + | { kind: "agent"; agentId: string } + | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } + | { kind: "terminal"; terminalId: string } + | { kind: "browser"; browserId: string } + | WorkspaceFileTabTarget + | WorkspaceWorkingDiffTabTarget + | { kind: "setup"; workspaceId: string } + | { kind: "commit_diff"; sha: string }; + +export interface WorkspaceTab { + tabId: string; + target: WorkspaceTabTarget; + createdAt: number; +} + +export function buildWorkspaceTabPersistenceKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + const serverId = input.serverId.trim(); + const workspaceId = input.workspaceId.trim(); + if (!serverId || !workspaceId) { + return null; + } + return `${serverId}:${workspaceId}`; +} diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 0d5c050be..7dc86a61a 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -8,11 +8,8 @@ import { type WorktreeArchiveWarningLabels, } from "@/git/worktree-archive-warning"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; -import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void { @@ -20,7 +17,6 @@ function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: str if (workspaceKey) { useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey); } - useWorkspaceTabsStore.getState().purgeWorkspace(input); } export interface ArchiveWorkspaceInput {