From 46b401eac6af26d951f5dea764351e2aee310efd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 26 Aug 2026 15:29:17 +0200 Subject: [PATCH] fix(app): route workspace actions semantically (#3896) * fix(app): route workspace actions semantically Separate Explorer visibility from view navigation, and give composer changes and pull request opens semantic routing owners. Unify diff destinations, keep compact PR navigation in Explorer, and derive maximize availability from visible workspace panes. * test(app): cover composer changes routing Update the browser journey for the Explorer-first composer action, then assert the second press follows the shared diff destination. --- docs/explorer-sidebar.md | 19 ++- .../e2e/browser/composer-diff-stat.spec.ts | 36 +++- .../components/split-container-focus.test.ts | 16 ++ .../src/components/split-container-focus.ts | 17 ++ .../app/src/components/split-container.tsx | 21 +-- .../app/src/composer/draft/workspace-tab.tsx | 5 +- packages/app/src/git/diff-pane.tsx | 17 +- packages/app/src/hooks/use-settings/index.ts | 2 + .../src/hooks/use-settings/storage.test.ts | 31 ++++ .../app/src/hooks/use-settings/storage.ts | 35 ++-- packages/app/src/i18n/resources/en.ts | 11 +- packages/app/src/panels/agent-panel.tsx | 5 +- packages/app/src/panels/agent-tracks.tsx | 5 +- packages/app/src/panels/diff-panel.tsx | 5 +- .../settings/layout/layout-section.tsx | 49 ++++-- .../screens/workspace/workspace-screen.tsx | 10 +- .../workspace-tabs/explorer-sidebar.test.ts | 37 ++++- .../src/workspace-tabs/explorer-sidebar.ts | 3 +- .../app/src/workspace-tabs/open-beside.ts | 21 ++- .../open-supporting-view.test.ts | 156 +++++++++++++++--- .../workspace-tabs/open-supporting-view.ts | 85 +++++----- 21 files changed, 440 insertions(+), 146 deletions(-) diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 5d1e56ceef..30d074a151 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -28,8 +28,11 @@ workspace, including the header. It has its own persisted width and resize handl never read or modify that width. `packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request -opens. Compact and wide native layouts select the matching Explorer tab; desktop layouts follow the -source-specific side-pane preference. Callers request the content and never choose the shell. +opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens +follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or +Explorer sidebar setting. Callers request the content and never choose the shell. +The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then +routes later presses to the working diff through the shared diff preference. The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes that pane from the workspace split tree and docks it separately. Persisted identifiers retain the @@ -40,7 +43,7 @@ toggles the singleton Files and Changes views. Individual tab menus close instan compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace focus. -Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined +Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Wide native layouts without pane splits use the same combined content in a resizable inline dock; opening a file leaves that dock visible. Both presentations keep their selection in the panel store @@ -70,9 +73,13 @@ new target and never yanks an existing tab out of a user-selected pane. ## Routing preferences -Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files, -Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches -default off. Mobile ignores them. +Desktop **Settings → Layout → Open location** has independent Main panel or On the side choices for +Explorer Files, diffs, chat files, files opened from diffs, and subagents. They default to Main +panel. Mobile ignores them. + +Pull requests have a three-way open location: Main panel, On the side, or Explorer sidebar. Explorer +sidebar is the default. Compact layouts always open pull requests in Explorer regardless of this +desktop preference. Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 174ecf70ed..5926f30c73 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -2,7 +2,7 @@ import { rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { openFilesPanel } from "../support/helpers/workspace-tabs"; +import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs"; const APP_SETTINGS_KEY = "@paseo:app-settings"; @@ -10,6 +10,22 @@ function visibleMainPane(page: Page) { return page.getByTestId("workspace-pane-main").filter({ visible: true }); } +function composerChangesPill(page: Page) { + return page.getByTestId("composer-diff-stat-pill"); +} + +async function revealComposerChangesInExplorer(page: Page) { + await composerChangesPill(page).click(); + + const explorer = await ensureExplorerSidebar(page); + await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0); +} + +async function openComposerDiff(page: Page) { + await composerChangesPill(page).click(); +} + async function seedChangedAgent(repoPrefix: string) { const workspace = await seedMockAgentWorkspace({ repoPrefix, @@ -38,9 +54,11 @@ async function seedChangedAgent(repoPrefix: string) { } } -test("composer diff stat opens Changes in the configured side pane", async ({ page }) => { +test("composer diff stat reveals Changes, then opens the diff in the configured side pane", async ({ + page, +}) => { await page.addInitScript((settingsKey) => { - localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { changesLinks: true } })); + localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { diffs: true } })); }, APP_SETTINGS_KEY); const workspace = await seedChangedAgent("composer-diff-stat-side-"); @@ -51,11 +69,12 @@ test("composer diff stat opens Changes in the configured side pane", async ({ pa agentId: workspace.agentId, }); - const pill = page.getByTestId("composer-diff-stat-pill"); + const pill = composerChangesPill(page); await expect(pill).toBeVisible({ timeout: 30_000 }); await expect(pill).toContainText("+2"); await expect(pill).toContainText("-0"); - await pill.click(); + await revealComposerChangesInExplorer(page); + await openComposerDiff(page); const sidePane = page .locator('[data-testid^="workspace-pane-"]') @@ -107,7 +126,9 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a } }); -test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => { +test("composer diff stat reveals Changes, then opens the diff in the focused pane by default", async ({ + page, +}) => { const workspace = await seedChangedAgent("composer-diff-stat-tab-"); try { @@ -117,7 +138,8 @@ test("composer diff stat opens Changes in the focused pane by default", async ({ agentId: workspace.agentId, }); - await page.getByTestId("composer-diff-stat-pill").click(); + await revealComposerChangesInExplorer(page); + await openComposerDiff(page); const mainPane = visibleMainPane(page); await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({ diff --git a/packages/app/src/components/split-container-focus.test.ts b/packages/app/src/components/split-container-focus.test.ts index 14ee9ba3c5..69c4d6e639 100644 --- a/packages/app/src/components/split-container-focus.test.ts +++ b/packages/app/src/components/split-container-focus.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + hasMultipleVisiblePanes, resolveSplitContainerRoot, splitNodeContainsPane, } from "@/components/split-container-focus"; @@ -9,6 +10,10 @@ const pane = (id: string): SplitNode => ({ kind: "pane", pane: { id, tabIds: [], focusedTabId: null }, }); +const hiddenPane = (id: string): SplitNode => ({ + kind: "pane", + pane: { id, tabIds: [], focusedTabId: null, hidden: true }, +}); const root: SplitNode = { kind: "group", group: { @@ -45,4 +50,15 @@ describe("split focus root", () => { resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }), ).toEqual({ root, usesFallbackStrip: false }); }); + + it("offers pane maximize only when another pane is visible", () => { + expect(hasMultipleVisiblePanes(root)).toBe(true); + expect(hasMultipleVisiblePanes(pane("left"))).toBe(false); + expect( + hasMultipleVisiblePanes({ + ...root, + group: { ...root.group, children: [pane("left"), hiddenPane("right")] }, + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/components/split-container-focus.ts b/packages/app/src/components/split-container-focus.ts index 8d41a54f28..d0824d2ddd 100644 --- a/packages/app/src/components/split-container-focus.ts +++ b/packages/app/src/components/split-container-focus.ts @@ -20,6 +20,23 @@ export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean return node.group.children.some((child) => splitNodeContainsPane(child, paneId)); } +/** Whether a workspace pane has another visible pane it can be maximized over. */ +export function hasMultipleVisiblePanes(node: SplitNode): boolean { + let visiblePaneCount = 0; + const visit = (current: SplitNode): void => { + if (current.kind === "pane") { + if (current.pane.hidden !== true) visiblePaneCount += 1; + return; + } + for (const child of current.group.children) { + visit(child); + if (visiblePaneCount > 1) return; + } + }; + visit(node); + return visiblePaneCount > 1; +} + function findPane(node: SplitNode, paneId: string): SplitPane | null { if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null; for (const child of node.group.children) { diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 9ab461eeca..5f467785d7 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -41,6 +41,7 @@ import { } from "@/components/explorer-sidebar-layout"; import { RetainedPanel } from "@/components/retained-panel"; import { + hasMultipleVisiblePanes, resolveSplitContainerRoot, splitNodeContainsPane, } from "@/components/split-container-focus"; @@ -185,7 +186,7 @@ interface SplitNodeViewProps extends Omit< tabDropPreview: TabDropPreview | null; windowChromeCorners: WindowChromeCorners; maximizedPaneId: string | null; - workspaceHasSplits: boolean; + workspaceHasMultiplePanes: boolean; onTogglePaneMaximized: (paneId: string) => void; } @@ -374,18 +375,18 @@ export function SplitContainer({ () => removePaneFromSplitTree(layout.root, explorerSidebarPaneId), [layout.root, explorerSidebarPaneId], ); - const workspaceHasSplits = mainRoot?.kind === "group"; + const workspaceHasMultiplePanes = Boolean(mainRoot && hasMultipleVisiblePanes(mainRoot)); useEffect(() => { if ( maximizedPaneId && (focusModeEnabled || - !workspaceHasSplits || + !workspaceHasMultiplePanes || !mainRoot || !splitNodeContainsPane(mainRoot, maximizedPaneId)) ) { setMaximizedPane(null); } - }, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasSplits]); + }, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasMultiplePanes]); const handleTogglePaneMaximized = useCallback( (paneId: string) => { setMaximizedPane((current) => @@ -693,7 +694,7 @@ export function SplitContainer({ tabDropPreview={tabDropPreview} windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners} maximizedPaneId={maximizedPaneId} - workspaceHasSplits={workspaceHasSplits} + workspaceHasMultiplePanes={workspaceHasMultiplePanes} onTogglePaneMaximized={handleTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} @@ -955,7 +956,7 @@ function SplitNodeView({ tabDropPreview, windowChromeCorners, maximizedPaneId, - workspaceHasSplits, + workspaceHasMultiplePanes, onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, @@ -1041,7 +1042,7 @@ function SplitNodeView({ dropPreview={dropPreview} tabDropPreview={tabDropPreview} maximizedPaneId={maximizedPaneId} - workspaceHasSplits={workspaceHasSplits} + workspaceHasMultiplePanes={workspaceHasMultiplePanes} onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} @@ -1095,7 +1096,7 @@ function SplitNodeView({ tabDropPreview={tabDropPreview} windowChromeCorners={windowChromeCorners} maximizedPaneId={maximizedPaneId} - workspaceHasSplits={workspaceHasSplits} + workspaceHasMultiplePanes={workspaceHasMultiplePanes} onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} @@ -1153,7 +1154,7 @@ function SplitPaneView({ dropPreview, tabDropPreview, maximizedPaneId, - workspaceHasSplits, + workspaceHasMultiplePanes, onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, @@ -1285,7 +1286,7 @@ function SplitPaneView({ tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null } showPaneSplitActions={!focusModeEnabled} - showPaneMaximizeAction={workspaceHasSplits && !focusModeEnabled} + showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled} paneMaximized={paneId === maximizedPaneId} onTogglePaneMaximized={handleTogglePaneMaximized} onSplitRight={handleSplitRight} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 8f8d485a1a..4d6f555fec 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -52,7 +52,7 @@ import { buildWorkspaceTabPersistenceKey, type WorkspaceDraftTabSetup, } from "@/workspace-tabs/model"; -import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view"; +import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; const EMPTY_PENDING_PERMISSIONS = new Map(); @@ -453,8 +453,7 @@ export function WorkspaceDraftAgentTab({ if (attachment.kind !== "review") { return; } - openWorkspaceSupportingView({ - view: "changes", + openWorkspaceChanges({ isCompact: isCompactFormFactor, workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }), checkout: { serverId, cwd: composerState.workingDir, isGit: true }, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index ba3e1c44f7..71470b2646 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -102,8 +102,8 @@ import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; import { PullRequestStateIcon } from "@/git/pull-request-state-icon"; import { openExternalUrl } from "@/utils/open-external-url"; -import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view"; -import type { OpenInSidePanePreferences } from "@/hooks/use-settings"; +import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; +import type { PullRequestOpenLocation } from "@/hooks/use-settings"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -1511,13 +1511,13 @@ function useDiffTabNavigation({ workspaceId, cwd, isMobile, - openInSidePane, + pullRequestOpenLocation, }: { serverId: string; workspaceId?: string | null; cwd: string; isMobile: boolean; - openInSidePane: OpenInSidePanePreferences; + pullRequestOpenLocation: PullRequestOpenLocation; }) { const openTab = useWorkspaceLayoutStore((state) => state.openTab); const openWorkspaceTab = useCallback( @@ -1545,14 +1545,13 @@ function useDiffTabNavigation({ ); const openPullRequest = useCallback(() => { if (!persistenceKey) return; - openWorkspaceSupportingView({ - view: "pull-request", + openWorkspacePullRequest({ isCompact: isMobile, workspaceKey: persistenceKey, checkout: { serverId, cwd, isGit: true }, - preferences: openInSidePane, + destination: pullRequestOpenLocation, }); - }, [cwd, isMobile, openInSidePane, persistenceKey, serverId]); + }, [cwd, isMobile, persistenceKey, pullRequestOpenLocation, serverId]); return { openDiff, openCommit, @@ -1633,7 +1632,7 @@ export function ChangesSurface({ workspaceId, cwd, isMobile, - openInSidePane: appSettings.openInSidePane, + pullRequestOpenLocation: appSettings.pullRequestOpenLocation, }); const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 514d2cd355..434e064453 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -37,6 +37,7 @@ import { saveAppSettings as saveAppSettingsPure, type AppSettings, type OpenInSidePanePreferences, + type PullRequestOpenLocation, type DesktopSettingsBridge, type KeyValueStorage, type ReleaseChannel, @@ -73,6 +74,7 @@ export type { AppSettings, AppLanguage, OpenInSidePanePreferences, + PullRequestOpenLocation, DesktopSettingsBridge, KeyValueStorage, ReleaseChannel, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 62903c7433..be9bef317a 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -197,6 +197,37 @@ describe("loadAppSettingsFromStorage", () => { expect(result.chatOutlineEnabled).toBe(false); }); + it("collapses legacy diff destinations into the former Explorer choice", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + openInSidePane: { explorerChanges: true, changesLinks: false }, + }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.openInSidePane.diffs).toBe(true); + expect(result.openInSidePane).not.toHaveProperty("explorerChanges"); + expect(result.openInSidePane).not.toHaveProperty("changesLinks"); + }); + + it("defaults PRs to Explorer and preserves the legacy side choice", async () => { + const defaults = await loadAppSettingsFromStorage(makeDeps()); + const legacySide = await loadAppSettingsFromStorage( + makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ openInSidePane: { pullRequests: true } }), + }), + }), + ); + + expect(defaults.pullRequestOpenLocation).toBe("explorer"); + expect(legacySide.pullRequestOpenLocation).toBe("side"); + expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests"); + }); + it("uses the native terminal renderer by default", async () => { const deps = makeDeps(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 5233beaf5d..4bdf13944c 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -30,6 +30,7 @@ export type SendBehavior = ActiveTurnBehavior | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; +export type PullRequestOpenLocation = "main" | "side" | "explorer"; /** What a sidebar workspace row shows in the space to the right of its title. */ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; export type ToolCallDetailLevel = "overview" | "detailed"; @@ -87,26 +88,23 @@ export interface AppSettings { vimKeybindings: boolean; /** Desktop-only preferences for implicit opens into the ordinary side pane. */ openInSidePane: OpenInSidePanePreferences; + pullRequestOpenLocation: PullRequestOpenLocation; } export interface OpenInSidePanePreferences { explorerFiles: boolean; - explorerChanges: boolean; + diffs: boolean; chatFiles: boolean; diffFiles: boolean; subagents: boolean; - pullRequests: boolean; - changesLinks: boolean; } export const DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES: OpenInSidePanePreferences = { explorerFiles: false, - explorerChanges: false, + diffs: false, chatFiles: false, diffFiles: false, subagents: false, - pullRequests: false, - changesLinks: false, }; export interface Settings extends AppSettings { @@ -137,6 +135,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { chatOutlineEnabled: true, vimKeybindings: false, openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, + pullRequestOpenLocation: "explorer", }; export const DEFAULT_APP_SETTINGS: Settings = { @@ -232,14 +231,26 @@ const StoredAppSettingsSchema = z openInSidePane: z .object({ explorerFiles: z.boolean().catch(false), - explorerChanges: z.boolean().catch(false), + diffs: z.boolean().optional(), + // COMPAT(diffDestinationPreference): legacy split preferences, remove after 2027-02-26. + explorerChanges: z.boolean().optional(), + changesLinks: z.boolean().optional(), chatFiles: z.boolean().catch(false), diffFiles: z.boolean().catch(false), subagents: z.boolean().catch(false), - pullRequests: z.boolean().catch(false), - changesLinks: z.boolean().catch(false), + // COMPAT(pullRequestOpenLocation): legacy side-pane toggle, remove after 2027-02-26. + pullRequests: z.boolean().optional(), }) - .catch(DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES), + .transform(({ explorerChanges, changesLinks, pullRequests, ...preferences }) => ({ + ...preferences, + diffs: preferences.diffs ?? explorerChanges ?? changesLinks ?? false, + legacyPullRequestsInSidePane: pullRequests, + })) + .catch({ + ...DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, + legacyPullRequestsInSidePane: undefined, + }), + pullRequestOpenLocation: z.enum(["main", "side", "explorer"]).optional(), // COMPAT(explorerSidebarRouting): replaced by source-specific side-pane preferences in v0.6. openSupportingTabsInSidePanel: z.boolean().optional().catch(undefined), // COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key. @@ -247,6 +258,7 @@ const StoredAppSettingsSchema = z releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined), }) .transform((stored) => { + const { legacyPullRequestsInSidePane, ...openInSidePane } = stored.openInSidePane; const needsWrite = (stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) || stored.contentFontSize === undefined; @@ -264,6 +276,9 @@ const StoredAppSettingsSchema = z stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed"); return { ...stored, + openInSidePane, + pullRequestOpenLocation: + stored.pullRequestOpenLocation ?? (legacyPullRequestsInSidePane ? "side" : "explorer"), uiBaseFontSize, contentFontSize: stored.contentFontSize ?? uiBaseFontSize, sidebarChecksDisplay, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c0b3fa82d4..fb29fb1d92 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1892,15 +1892,16 @@ export const en = { destinations: { main: "Main panel", side: "On the side", + explorer: "Explorer sidebar", }, sources: { explorerFiles: { label: "Selecting a file in Explorer", description: "Open files selected in the Explorer sidebar beside your work", }, - explorerChanges: { - label: "Selecting a change in Explorer", - description: "Open diffs selected in the Explorer sidebar beside your work", + diffs: { + label: "Opening a diff", + description: "Open diffs from Explorer and agent conversations beside your work", }, chatFiles: { label: "Opening a file from an agent chat", @@ -1918,10 +1919,6 @@ export const en = { label: "Opening a pull request from Changes", description: "Open pull request details beside Changes", }, - changesLinks: { - label: "Opening Changes from an agent", - description: "Open diff stats and review attachments beside the conversation", - }, }, }, }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 8237a3a1c9..515262bb70 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -94,7 +94,7 @@ import { } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view"; +import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PendingPermission } from "@/types/shared"; @@ -1626,8 +1626,7 @@ function ActiveAgentComposer({ if (attachment.kind !== "review") { return; } - openWorkspaceSupportingView({ - view: "changes", + openWorkspaceChanges({ isCompact: isCompactFormFactor, workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), checkout: { serverId, cwd, isGit: true }, diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index be64401783..3ea15696c4 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -18,7 +18,7 @@ import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside"; -import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view"; +import { openComposerChanges } from "@/workspace-tabs/open-supporting-view"; /** * The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above @@ -99,8 +99,7 @@ export const AgentTracks = memo(function AgentTracks({ if (!workspaceKey) { return; } - openWorkspaceSupportingView({ - view: "changes", + openComposerChanges({ isCompact, workspaceKey, checkout: { serverId, cwd, isGit: true }, diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index e97dbcf17e..adad98e690 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -100,8 +100,7 @@ function ChangesPanel() { const isTree = target.kind === "changes_tree"; const handleOpenFile = useCallback( - (path: string) => - openPreferredTarget({ kind: "file", path }, isTree ? "explorerChanges" : "diffFiles"), + (path: string) => openPreferredTarget({ kind: "file", path }, isTree ? "diffs" : "diffFiles"), [isTree, openPreferredTarget], ); @@ -109,7 +108,7 @@ function ChangesPanel() { (path: string) => openPreferredTarget( { kind: "working_diff", focusPath: path, focusRequestId: Date.now() }, - "explorerChanges", + "diffs", ), [openPreferredTarget], ); diff --git a/packages/app/src/screens/settings/layout/layout-section.tsx b/packages/app/src/screens/settings/layout/layout-section.tsx index bcd427cf1a..f15c451fbc 100644 --- a/packages/app/src/screens/settings/layout/layout-section.tsx +++ b/packages/app/src/screens/settings/layout/layout-section.tsx @@ -4,21 +4,23 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; -import { useAppSettings, type OpenInSidePanePreferences } from "@/hooks/use-settings"; +import { + useAppSettings, + type OpenInSidePanePreferences, + type PullRequestOpenLocation, +} from "@/hooks/use-settings"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; const SOURCES = [ "explorerFiles", - "explorerChanges", + "diffs", "chatFiles", "diffFiles", "subagents", - "pullRequests", - "changesLinks", ] as const satisfies readonly (keyof OpenInSidePanePreferences)[]; -type OpenDestination = "main" | "side"; +type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests"; function destinationTriggerStyle({ pressed, @@ -29,20 +31,21 @@ function destinationTriggerStyle({ function LayoutPreferenceRow({ source, - value, + destination, first, + allowExplorer, onDestinationChange, }: { - source: keyof OpenInSidePanePreferences; - value: boolean; + source: LayoutPreferenceSource; + destination: PullRequestOpenLocation; first: boolean; + allowExplorer?: boolean; onDestinationChange: ( - source: keyof OpenInSidePanePreferences, - destination: OpenDestination, + source: LayoutPreferenceSource, + destination: PullRequestOpenLocation, ) => void; }) { const { t } = useTranslation(); - const destination: OpenDestination = value ? "side" : "main"; const destinationLabel = t(`settings.layout.openInSidePane.destinations.${destination}`); const selectMain = useCallback( () => onDestinationChange(source, "main"), @@ -52,6 +55,10 @@ function LayoutPreferenceRow({ () => onDestinationChange(source, "side"), [onDestinationChange, source], ); + const selectExplorer = useCallback( + () => onDestinationChange(source, "explorer"), + [onDestinationChange, source], + ); const label = t(`settings.layout.openInSidePane.sources.${source}.label`); return ( @@ -73,6 +80,11 @@ function LayoutPreferenceRow({ {t("settings.layout.openInSidePane.destinations.side")} + {allowExplorer ? ( + + {t("settings.layout.openInSidePane.destinations.explorer")} + + ) : null} @@ -83,7 +95,11 @@ export function LayoutSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); const handleDestinationChange = useCallback( - (source: keyof OpenInSidePanePreferences, destination: OpenDestination) => { + (source: LayoutPreferenceSource, destination: PullRequestOpenLocation) => { + if (source === "pullRequests") { + void updateSettings({ pullRequestOpenLocation: destination }); + return; + } void updateSettings({ openInSidePane: { ...settings.openInSidePane, [source]: destination === "side" }, }); @@ -97,12 +113,19 @@ export function LayoutSection() { ))} + ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5cfe2281bf..7ff4917dc3 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -56,7 +56,7 @@ import { openPreferredWorkspaceTarget, openWorkspaceTargetBeside, } from "@/workspace-tabs/open-beside"; -import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view"; +import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; @@ -1829,6 +1829,7 @@ function WorkspaceScreenContent({ [openTab], ); const openInSidePane = useSettings((settings) => settings.openInSidePane); + const pullRequestOpenLocation = useSettings((settings) => settings.pullRequestOpenLocation); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); @@ -2986,12 +2987,11 @@ function WorkspaceScreenContent({ return true; } if (action.target === "pull-request") { - openWorkspaceSupportingView({ - view: "pull-request", + openWorkspacePullRequest({ isCompact: isMobile, workspaceKey: persistenceKey, checkout: activeExplorerCheckout, - preferences: openInSidePane, + destination: pullRequestOpenLocation, }); return true; } @@ -3017,9 +3017,9 @@ function WorkspaceScreenContent({ activeExplorerCheckout, focusedPaneTabState.pane?.id, isMobile, - openInSidePane, openWorkspaceTabFocused, persistenceKey, + pullRequestOpenLocation, ], ); diff --git a/packages/app/src/workspace-tabs/explorer-sidebar.test.ts b/packages/app/src/workspace-tabs/explorer-sidebar.test.ts index c9bb74a6e0..af72c48194 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.test.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.test.ts @@ -11,6 +11,7 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ import { usePanelStore } from "@/stores/panel-store"; import { collectAllTabs, + findPaneById, selectExplorerSidebarPaneId, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; @@ -31,7 +32,11 @@ beforeEach(() => { sidePaneIdByWorkspace: {}, splitSizesByWorkspace: {}, }); - usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 } }); + usePanelStore.setState({ + mobilePanel: { target: "agent", revision: 0 }, + explorerTab: "files", + explorerTabByCheckout: {}, + }); }); describe("Explorer sidebar", () => { @@ -80,9 +85,35 @@ describe("Explorer sidebar", () => { workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT, }; - toggleExplorerSidebar(input); - expect(isExplorerSidebarOpen(input)).toBe(true); + openExplorerSidebarView({ ...input, view: "files" }); toggleExplorerSidebar(input); expect(isExplorerSidebarOpen(input)).toBe(false); + toggleExplorerSidebar(input); + expect(isExplorerSidebarOpen(input)).toBe(true); + const openedState = useWorkspaceLayoutStore.getState(); + const openedLayout = openedState.layoutByWorkspace[WORKSPACE_KEY]; + const explorerPaneId = selectExplorerSidebarPaneId(openedState, WORKSPACE_KEY); + const explorerPane = + openedLayout && explorerPaneId ? findPaneById(openedLayout.root, explorerPaneId) : null; + const activeExplorerTarget = + openedLayout && explorerPane + ? collectAllTabs(openedLayout.root).find((tab) => tab.tabId === explorerPane.focusedTabId) + ?.target.kind + : null; + expect(activeExplorerTarget).toBe("files"); + }); + + it("toggles the compact Explorer without changing its selected view", () => { + usePanelStore.getState().setExplorerTabForCheckout({ ...CHECKOUT, tab: "files" }); + const input = { + isCompact: true, + workspaceKey: WORKSPACE_KEY, + checkout: CHECKOUT, + }; + + toggleExplorerSidebar(input); + + expect(isExplorerSidebarOpen(input)).toBe(true); + expect(usePanelStore.getState().explorerTab).toBe("files"); }); }); diff --git a/packages/app/src/workspace-tabs/explorer-sidebar.ts b/packages/app/src/workspace-tabs/explorer-sidebar.ts index 00bc40f456..ab2d7db716 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.ts @@ -95,11 +95,10 @@ export function toggleExplorerSidebar(input: ExplorerSidebarInput): void { return; } if (!input.workspaceKey) return; - const view: ExplorerSidebarView = input.checkout?.isGit ? "changes" : "files"; if (isExplorerSidebarOpen(input)) { hideExplorerSidebar(input); } else { - openExplorerSidebarView({ ...input, view }); + showExplorerSidebar(input); } } diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index 9b5477dc7d..7acda716c1 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -12,6 +12,7 @@ import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; export type OpenInSidePaneSource = keyof OpenInSidePanePreferences; +export type WorkspaceTargetOpenLocation = "main" | "side"; interface OpenWorkspaceTargetInput { workspaceKey: string | null; @@ -25,6 +26,11 @@ export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetIn preferences: OpenInSidePanePreferences; } +interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput { + isCompact: boolean; + location: WorkspaceTargetOpenLocation; +} + interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTargetInput { serverId: string; workspaceId: string; @@ -77,6 +83,19 @@ function canReplacePreview(input: { /** Opens an implicit target according to its source-specific desktop preference. */ export function openPreferredWorkspaceTarget( input: OpenPreferredWorkspaceTargetInput, +): string | null { + return openWorkspaceTargetAtLocation({ + isCompact: input.isCompact, + workspaceKey: input.workspaceKey, + target: input.target, + location: input.preferences[input.source] ? "side" : "main", + parentTabId: input.parentTabId, + }); +} + +/** Opens an implicit target at the user's preferred main or side location. */ +export function openWorkspaceTargetAtLocation( + input: OpenWorkspaceTargetAtLocationInput, ): string | null { if (!input.workspaceKey) return null; const store = useWorkspaceLayoutStore.getState(); @@ -85,7 +104,7 @@ export function openPreferredWorkspaceTarget( layout && collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)), ); - const shouldOpenBeside = !input.isCompact && input.preferences[input.source]; + const shouldOpenBeside = !input.isCompact && input.location === "side"; let placement: WorkspaceTabPlacement | undefined; if (shouldOpenBeside && !targetAlreadyExists) { const paneId = store.ensureSidePane(input.workspaceKey); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.test.ts b/packages/app/src/workspace-tabs/open-supporting-view.test.ts index ec2c773f54..c0fa1c5927 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.test.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.test.ts @@ -10,8 +10,16 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings"; import { usePanelStore } from "@/stores/panel-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view"; +import { + collectAllTabs, + findPaneById, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { + openComposerChanges, + openWorkspaceChanges, + openWorkspacePullRequest, +} from "@/workspace-tabs/open-supporting-view"; const WORKSPACE_KEY = "server-1:workspace-1"; const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; @@ -30,13 +38,9 @@ beforeEach(() => { }); }); -describe("openWorkspaceSupportingView", () => { - it.each([ - ["changes", "changes"], - ["pull-request", "pr"], - ] as const)("opens %s in the compact Explorer", (view, explorerTab) => { - openWorkspaceSupportingView({ - view, +describe("openWorkspaceChanges", () => { + it("opens Changes in the compact Explorer", () => { + openWorkspaceChanges({ isCompact: true, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT, @@ -44,21 +48,133 @@ describe("openWorkspaceSupportingView", () => { }); expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(usePanelStore.getState().explorerTab).toBe(explorerTab); + expect(usePanelStore.getState().explorerTab).toBe("changes"); + }); +}); + +describe("openComposerChanges", () => { + const input = { + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + checkout: CHECKOUT, + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }; + + it("opens the desktop Explorer on Changes when it is closed", () => { + openComposerChanges(input); + + const state = useWorkspaceLayoutStore.getState(); + const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[WORKSPACE_KEY]; + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const explorerPane = + layout && explorerPaneId ? findPaneById(layout.root, explorerPaneId) : null; + const explorerTabKinds = layout + ? collectAllTabs(layout.root) + .filter((tab) => explorerPane?.tabIds.includes(tab.tabId)) + .map((tab) => tab.target.kind) + : []; + + expect(explorerPane?.hidden).not.toBe(true); + expect(explorerTabKinds).toContain("changes_tree"); + expect(layout && collectAllTabs(layout.root).map((tab) => tab.target.kind)).not.toContain( + "working_diff", + ); + }); + + it("opens the diff through Changes link routing when the desktop Explorer is open", () => { + openComposerChanges(input); + openComposerChanges({ + ...input, + preferences: { ...input.preferences, diffs: true }, + }); + + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const sidePaneId = state.sidePaneIdByWorkspace[WORKSPACE_KEY]; + const sidePane = layout && sidePaneId ? findPaneById(layout.root, sidePaneId) : null; + const sideTabKinds = layout + ? collectAllTabs(layout.root) + .filter((tab) => sidePane?.tabIds.includes(tab.tabId)) + .map((tab) => tab.target.kind) + : []; + + expect(sideTabKinds).toContain("working_diff"); + }); + + it("keeps opening the compact Explorer on Changes", () => { + openComposerChanges({ ...input, isCompact: true }); + openComposerChanges({ ...input, isCompact: true }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(usePanelStore.getState().explorerTab).toBe("changes"); expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); }); +}); - it("uses the combined Explorer dock on native layouts without pane splits", () => { - openWorkspaceSupportingView({ - view: "pull-request", - isCompact: false, - supportsPaneSplits: false, - workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, - preferences: DEFAULT_APP_SETTINGS.openInSidePane, +describe("openWorkspacePullRequest", () => { + const input = { + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + checkout: CHECKOUT, + }; + + it.each(["main", "side", "explorer"] as const)( + "opens the compact PR view in Explorer when the desktop preference is %s", + (destination) => { + openWorkspacePullRequest({ ...input, isCompact: true, destination }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(usePanelStore.getState().explorerTab).toBe("pr"); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + }, + ); + + it("opens PRs in Explorer by default", () => { + openWorkspacePullRequest({ + ...input, + destination: DEFAULT_APP_SETTINGS.pullRequestOpenLocation, }); + + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[WORKSPACE_KEY]; + const explorerPane = + layout && explorerPaneId ? findPaneById(layout.root, explorerPaneId) : null; + const pullRequestTab = layout + ? collectAllTabs(layout.root).find((tab) => tab.target.kind === "pull_request") + : null; + + expect(explorerPane?.tabIds).toContain(pullRequestTab?.tabId); + expect(explorerPane?.hidden).not.toBe(true); + }); + + it("opens PRs in the main panel when configured", () => { + openWorkspacePullRequest({ ...input, destination: "main" }); - expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(usePanelStore.getState().explorerTab).toBe("pr"); + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const mainPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null; + const pullRequestTab = layout + ? collectAllTabs(layout.root).find((tab) => tab.target.kind === "pull_request") + : null; + + expect(mainPane?.tabIds).toContain(pullRequestTab?.tabId); + expect(state.sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + }); + + it("opens PRs in the side panel when configured", () => { + openWorkspacePullRequest({ ...input, destination: "side" }); + + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const sidePaneId = state.sidePaneIdByWorkspace[WORKSPACE_KEY]; + const sidePane = layout && sidePaneId ? findPaneById(layout.root, sidePaneId) : null; + const pullRequestTab = layout + ? collectAllTabs(layout.root).find((tab) => tab.target.kind === "pull_request") + : null; + + expect(sidePane?.tabIds).toContain(pullRequestTab?.tabId); }); }); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index 0c6d495978..047cdd3f97 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -1,66 +1,69 @@ -import type { OpenInSidePanePreferences } from "@/hooks/use-settings"; +import type { OpenInSidePanePreferences, PullRequestOpenLocation } from "@/hooks/use-settings"; import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { + isExplorerSidebarOpen, openExplorerSidebarView, usesCompactExplorerSidebar, type ExplorerSidebarView, } from "@/workspace-tabs/explorer-sidebar"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { openPreferredWorkspaceTarget, - type OpenInSidePaneSource, + openWorkspaceTargetAtLocation, } from "@/workspace-tabs/open-beside"; -export type WorkspaceSupportingView = "changes" | "pull-request"; - -interface SupportingViewRoute { - explorerView: ExplorerSidebarView; - target: WorkspaceTabTarget; - source: OpenInSidePaneSource; -} - -const SUPPORTING_VIEW_ROUTES: Record = { - changes: { - explorerView: "changes", - target: { kind: "working_diff" }, - source: "changesLinks", - }, - "pull-request": { - explorerView: "pr", - target: { kind: "pull_request" }, - source: "pullRequests", - }, -}; - -interface OpenWorkspaceSupportingViewInput { - view: WorkspaceSupportingView; +interface WorkspaceViewInput { isCompact: boolean; workspaceKey: string | null; checkout: ExplorerCheckoutContext | null; - preferences: OpenInSidePanePreferences; supportsPaneSplits?: boolean; } -/** Opens supporting workspace content in the shell appropriate for the current layout. */ -export function openWorkspaceSupportingView( - input: OpenWorkspaceSupportingViewInput, -): string | null { - const route = SUPPORTING_VIEW_ROUTES[input.view]; +interface OpenWorkspaceChangesInput extends WorkspaceViewInput { + preferences: OpenInSidePanePreferences; +} + +interface OpenWorkspacePullRequestInput extends WorkspaceViewInput { + destination: PullRequestOpenLocation; +} + +function openExplorerView(input: WorkspaceViewInput, view: ExplorerSidebarView): void { + openExplorerSidebarView({ ...input, view }); +} + +/** Opens the workspace Changes view according to the current layout and diff preference. */ +export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | null { if (usesCompactExplorerSidebar(input)) { - openExplorerSidebarView({ - isCompact: input.isCompact, - supportsPaneSplits: input.supportsPaneSplits, - workspaceKey: input.workspaceKey, - checkout: input.checkout, - view: route.explorerView, - }); + openExplorerView(input, "changes"); return null; } return openPreferredWorkspaceTarget({ isCompact: input.isCompact, workspaceKey: input.workspaceKey, - target: route.target, - source: route.source, + target: { kind: "working_diff" }, + source: "diffs", preferences: input.preferences, }); } + +/** Reveals Changes from the composer, then opens its diff on a subsequent desktop action. */ +export function openComposerChanges(input: OpenWorkspaceChangesInput): string | null { + if (usesCompactExplorerSidebar(input) || isExplorerSidebarOpen(input)) { + return openWorkspaceChanges(input); + } + openExplorerView(input, "changes"); + return null; +} + +/** Opens the workspace pull request at its semantic destination. */ +export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): string | null { + if (usesCompactExplorerSidebar(input) || input.destination === "explorer") { + openExplorerView(input, "pr"); + return null; + } + return openWorkspaceTargetAtLocation({ + isCompact: input.isCompact, + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + location: input.destination, + }); +}