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 {