From a264f41d4a010089753dc8a56c7b59e610e44ddd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 16:57:20 +0200 Subject: [PATCH] feat(app): retain Explorer across layout and appearance changes Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces. --- docs/explorer-sidebar.md | 35 +- .../browser/add-changed-file-to-chat.spec.ts | 80 +- packages/app/e2e/browser/changes-pane.spec.ts | 31 +- .../app/e2e/browser/explorer-sidebar.spec.ts | 252 +++++- packages/app/src/agent-stream/view.tsx | 5 - packages/app/src/app/_layout.tsx | 45 +- packages/app/src/app/h/[serverId]/_layout.tsx | 7 +- .../appearance-style-boundary.test.ts | 96 ++- .../components/compact-explorer-sidebar.tsx | 163 ---- .../src/components/headers/compact-header.tsx | 11 +- .../app/src/components/split-container.tsx | 720 ++---------------- .../components/workspace-tab-drag-context.ts | 12 + .../components/workspace-tab-drag-policy.ts | 9 + .../components/workspace-tab-drag-scope.tsx | 5 + .../workspace-tab-drag-scope.web.tsx | 494 ++++++++++++ .../app/src/composer/draft/workspace-tab.tsx | 3 +- packages/app/src/file-pane/pane.tsx | 71 +- packages/app/src/git/diff-pane.tsx | 3 +- .../app/src/git/pull-request-connection.tsx | 27 + .../git/pull-request-panel/use-data.test.ts | 4 +- .../src/git/pull-request-panel/use-data.ts | 11 +- packages/app/src/git/use-pr-status-query.ts | 8 +- packages/app/src/keyboard/shift/index.ts | 1 - .../keyboard/shift/internal/policy.test.ts | 15 - .../app/src/keyboard/shift/internal/policy.ts | 7 - .../explorer-open-gesture.tsx} | 3 +- packages/app/src/mobile-panels/gestures.ts | 26 +- .../app/src/mobile-panels/presentation.tsx | 47 +- packages/app/src/mobile-panels/provider.tsx | 3 - packages/app/src/navigation/themed-stack.tsx | 12 +- packages/app/src/panels/agent-panel.tsx | 4 +- packages/app/src/panels/agent-tracks.tsx | 5 +- .../src/panels/changes/presentation.test.ts | 22 + .../app/src/panels/changes/presentation.ts | 9 + packages/app/src/panels/diff-panel.tsx | 19 +- .../app/src/panels/pull-request-panel.tsx | 42 +- .../activity.integration.test.tsx | 249 ++++++ .../src/panels/pull-request/activity.test.ts | 51 ++ .../app/src/panels/pull-request/activity.ts | 12 + packages/app/src/panels/pull-request/data.ts | 11 + .../src/panels/pull-request/descriptor.tsx | 40 + .../app/src/panels/pull-request/discovery.ts | 65 ++ .../app/src/panels/pull-request/index.tsx | 56 +- .../panels/retained-chat-content.native.tsx | 11 + .../src/panels/retained-chat-content.test.tsx | 102 +++ .../app/src/panels/retained-chat-content.tsx | 12 +- packages/app/src/panels/terminal-panel.tsx | 15 +- .../app/src/panels/use-add-file-to-chat.ts | 6 +- .../screens/workspace/explorer-sidebar.tsx | 10 +- .../workspace/explorer-sidebar/frame-props.ts | 10 + .../workspace/explorer-sidebar/frame.tsx | 68 ++ .../explorer-sidebar/geometry-types.ts | 15 + .../workspace/explorer-sidebar/geometry.tsx | 86 +++ .../explorer-sidebar/geometry.web.tsx | 57 ++ .../workspace/workspace-desktop-tabs-row.tsx | 1 - .../workspace/workspace-explorer-toggle.tsx | 3 +- .../screens/workspace/workspace-screen.tsx | 265 +++---- .../workspace/workspace-tab-layout.test.ts | 26 + .../src/stores/explorer-checkout-context.ts | 5 - .../app/src/stores/explorer-tab-memory.ts | 34 - packages/app/src/stores/panel-store/index.ts | 56 +- .../app/src/stores/panel-store/state.test.ts | 139 +--- packages/app/src/stores/panel-store/state.ts | 77 +- .../app/src/stores/workspace-layout-store.ts | 59 +- .../workspace-tabs/explorer-sidebar.test.ts | 19 +- .../src/workspace-tabs/explorer-sidebar.ts | 32 +- .../app/src/workspace-tabs/navigation.test.ts | 87 +++ packages/app/src/workspace-tabs/navigation.ts | 19 + .../app/src/workspace-tabs/open-beside.ts | 57 +- .../open-supporting-view.test.ts | 11 +- .../workspace-tabs/open-supporting-view.ts | 40 +- 71 files changed, 2365 insertions(+), 1748 deletions(-) delete mode 100644 packages/app/src/components/compact-explorer-sidebar.tsx create mode 100644 packages/app/src/components/workspace-tab-drag-context.ts create mode 100644 packages/app/src/components/workspace-tab-drag-policy.ts create mode 100644 packages/app/src/components/workspace-tab-drag-scope.tsx create mode 100644 packages/app/src/components/workspace-tab-drag-scope.web.tsx create mode 100644 packages/app/src/git/pull-request-connection.tsx rename packages/app/src/{components/compact-explorer-sidebar-host.tsx => mobile-panels/explorer-open-gesture.tsx} (96%) create mode 100644 packages/app/src/panels/changes/presentation.test.ts create mode 100644 packages/app/src/panels/changes/presentation.ts create mode 100644 packages/app/src/panels/pull-request/activity.integration.test.tsx create mode 100644 packages/app/src/panels/pull-request/activity.test.ts create mode 100644 packages/app/src/panels/pull-request/activity.ts create mode 100644 packages/app/src/panels/pull-request/data.ts create mode 100644 packages/app/src/panels/pull-request/descriptor.tsx create mode 100644 packages/app/src/panels/pull-request/discovery.ts create mode 100644 packages/app/src/panels/retained-chat-content.native.tsx create mode 100644 packages/app/src/panels/retained-chat-content.test.tsx create mode 100644 packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts create mode 100644 packages/app/src/screens/workspace/explorer-sidebar/frame.tsx create mode 100644 packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts create mode 100644 packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx create mode 100644 packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx delete mode 100644 packages/app/src/stores/explorer-checkout-context.ts delete mode 100644 packages/app/src/stores/explorer-tab-memory.ts create mode 100644 packages/app/src/workspace-tabs/navigation.test.ts create mode 100644 packages/app/src/workspace-tabs/navigation.ts diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 1c055ede77..9fe6bdfd7b 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -11,7 +11,7 @@ contracts. ## Panel host contract -Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive +Every panel registers its supported `PaneHost` values and presentation. Launchers derive fixed-target labels and icons from that registration, filter by host, and never substitute one panel type for another. Tab moves reject unsupported destinations, and placement resolves only to a compatible pane. @@ -21,7 +21,10 @@ so a pane’s + menu omits each while that pane already contains it. Closing one item available again. Other compatible tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes. Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and -retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts. +retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and +close-gesture lifetime. Platform adapters own resizing and inset geometry. +The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget +can remount. Native retained content stays live, while web chat uses its existing deferred Freeze. ## Sheets @@ -40,11 +43,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab dismisses the sheets above it, since the tab would sit under them. The stack is not persisted. -Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag +Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside Close, so the title keeps its width. The sheets present -from inside the compact column's `SheetHostScope`, so their panels read the navigator and every +from the shared workspace's `SheetHostScope`, so their panels read the navigator and every workspace provider an ordinary pane has without a context bridge ([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). @@ -56,8 +59,12 @@ the sheet's tab into a pane, and edits the autosave has not started writing are ## Explorer sidebar `packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection. -On desktop, the shell is rendered outside the workspace split canvas so it divides the full -workspace, including the header. It has its own persisted width and resize handle. Main-pane splits +One Explorer host stays beside the center column across compact overlays and docks. Keep its +providers and gesture roots at the same React position when width or appearance changes. The +workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves +subscribe to theme changes; the file editor reconfigures its existing editor theme compartment. +Ordinary leaf routes retain the stack appearance policy. +The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits never read or modify that width. `packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request @@ -80,7 +87,8 @@ Other tabs reveal the close control on hover. The + menu opens compatible panels Web also supports dragging agents and terminals into Explorer. Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab -does not change workspace focus. +does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts +accept only reordering inside the source pane. Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer @@ -94,7 +102,18 @@ narrow columns; widening restores navigation without changing its saved open sta Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use -the sheet policy. +the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet +keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the +actual destination before revealing it, including deduplicated targets. + +Compact overlay selection and saved dock visibility are independent. Folding changes geometry, +not either mode’s visibility intent. Native workspace retention remains one workspace; web retains +up to ten with its existing inactive TTL. + +PR discovery belongs to the active workspace: split layouts discover while active, and other +layouts discover while Explorer is active. The rail requests summary status; selected visible PR +content also requests timeline data. Inactive consumers read cached data without request demand. +Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out. ## Side pane diff --git a/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts b/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts index c5d4d6cb15..af3ea5454f 100644 --- a/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts +++ b/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts @@ -2,48 +2,56 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { test, expect, type Page } from "../support/fixtures"; import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; -import { openChangesPanel } from "../support/helpers/workspace-tabs"; +import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs"; function visibleComposer(page: Page) { return page.locator("textarea[data-composer-input]").filter({ visible: true }).first(); } -test("adds a changed file to the focused chat without replacing its composer draft", async ({ - page, -}) => { - const workspace = await seedMockAgentWorkspace({ - repoPrefix: "add-file-to-chat-", - title: "Target chat", - }); - const relativePath = "src/changed file.ts"; - - try { - await mkdir(path.join(workspace.cwd, "src"), { recursive: true }); - await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n"); - await workspace.client.checkoutRefresh(workspace.cwd); - - await page.setViewportSize({ width: 1400, height: 900 }); - await openAgentRoute(page, { - workspaceId: workspace.workspaceId, - agentId: workspace.agentId, +for (const width of [1400, 390]) { + test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({ + page, + }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "add-file-to-chat-", + title: "Target chat", }); + const relativePath = "src/changed file.ts"; - const agentComposer = visibleComposer(page); - await expect(agentComposer).toBeEditable({ timeout: 30_000 }); - await agentComposer.fill("Preserve this thought"); + try { + await mkdir(path.join(workspace.cwd, "src"), { recursive: true }); + await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n"); + await workspace.client.checkoutRefresh(workspace.cwd); - await openChangesPanel(page); - const changedFile = page.getByText("changed file.ts", { exact: true }).first(); - await expect(changedFile).toBeVisible({ timeout: 30_000 }); - await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); - await page.getByTestId("diff-file-0-add-to-chat").click(); + await page.setViewportSize({ width, height: 900 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); - const attachment = page.getByTestId("composer-workspace-file-attachment-pill"); - await expect(attachment).toContainText("changed file.ts"); - await expect(attachment).toContainText(relativePath); - await expect(agentComposer).toHaveValue("Preserve this thought"); - await expect(agentComposer).toBeFocused(); - } finally { - await workspace.cleanup(); - } -}); + const agentComposer = visibleComposer(page); + await expect(agentComposer).toBeEditable({ timeout: 30_000 }); + await agentComposer.fill("Preserve this thought"); + + if (width < 600) await openChangesTreePanel(page); + else await openChangesPanel(page); + const changedFile = page.getByTestId("diff-file-0-toggle"); + await expect(changedFile).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await page.getByTestId("diff-file-0-add-to-chat").click(); + + const attachment = page.getByTestId("composer-workspace-file-attachment-pill"); + if (width < 600) + await expect( + page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }), + ).toHaveCount(0); + await expect(attachment).toContainText("changed file.ts"); + await expect(attachment).toContainText(relativePath); + await expect(agentComposer).toHaveValue("Preserve this thought"); + if (width >= 600) await expect(agentComposer).toBeFocused(); + else await expect(agentComposer).toBeEditable(); + } finally { + await workspace.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 377adbb3b6..2218e1bf3b 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -833,13 +833,15 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page }) await openWorkspaceChanges(page, workspace); await page.setViewportSize({ width: 480, height: 900 }); - const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }); + const compactChangesTab = page + .getByTestId("workspace-tab-changes_tree") + .filter({ visible: true }); if (!(await compactChangesTab.isVisible())) { await page.getByTestId("workspace-explorer-toggle").first().click(); } await expect(compactChangesTab).toBeVisible(); await compactChangesTab.click(); - const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true }); + const compactExplorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); await expect(compactExplorer.getByTestId("changes-header")).toBeVisible(); const actions = compactExplorer.getByTestId("changes-actions-menu-trigger"); @@ -876,6 +878,27 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page }) ).toContainText("Scroll long lines"); }); +test("compact Explorer renders inline hunks with a saved tree-only desktop preference", async ({ + page, +}) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await page.addInitScript( + ({ key }) => { + localStorage.setItem(key, JSON.stringify({ inlineDiff: false, layout: "split" })); + }, + { key: CHANGES_PREFERENCES_KEY }, + ); + const explorer = await openCompactChanges(page, workspace); + await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible(); + await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible(); + await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" }); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByTestId("diff-file-0-revert").click(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(BEFORE); +}); + test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); await useUnwrappedDiffLines(page); @@ -1674,10 +1697,10 @@ async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promis await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.reload(); await page.getByTestId("workspace-explorer-toggle").first().click(); - const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }); + const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true }); await expect(changesTab).toBeVisible({ timeout: 30_000 }); await changesTab.click(); - const explorer = page.getByTestId("explorer-content-area").filter({ visible: true }); + const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 }); return explorer; } diff --git a/packages/app/e2e/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts index 8526dbdb73..06a6994332 100644 --- a/packages/app/e2e/browser/explorer-sidebar.spec.ts +++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts @@ -1,3 +1,7 @@ +import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; +import { writeFile } from "node:fs/promises"; +import { openFileFromExplorer } from "../support/helpers/file-explorer"; +import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings"; import { expect, test } from "../support/fixtures"; import { gotoWorkspace, @@ -136,10 +140,9 @@ test("Explorer keeps Files and Changes close actions in the context menu", async await explorer.getByRole("button", { name: "New tab", exact: true }).click(); const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1); await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0); - await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible(); const entries = await menu.getByRole("menuitem").allTextContents(); expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan( @@ -191,13 +194,11 @@ test("Explorer keeps Files and Changes close actions in the context menu", async } }); -async function dragAgentIntoExplorer( +async function dragTabIntoExplorer( page: Parameters[0], - agentId: string, + tabId: string, ) { - const source = page - .getByTestId("workspace-pane-main") - .getByTestId(`workspace-tab-agent_${agentId}`); + const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`); const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); const sourceBox = await source.boundingBox(); const destinationBox = await destination.boundingBox(); @@ -213,7 +214,7 @@ async function dragAgentIntoExplorer( await page.mouse.up(); } -test("agents can still be dragged into Explorer when absent from its + menu", async ({ +test("moves agents into Explorer and keeps the shared Agent launcher available", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" }); @@ -230,14 +231,13 @@ test("agents can still be dragged into Explorer when absent from its + menu", as }); const main = page.getByTestId("workspace-pane-main"); await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); - await dragAgentIntoExplorer(page, agent.id); + await dragTabIntoExplorer(page, `agent_${agent.id}`); await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0); await explorer.getByRole("button", { name: "New tab", exact: true }).click(); const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); - await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1); await page.keyboard.press("Escape"); await testInfo.attach("agent-in-explorer", { body: await page.screenshot(), @@ -287,3 +287,233 @@ async function revealPhoneExplorer(page: Parameters[0]) { + await page.keyboard.press("Control+Alt+t"); +} + +test("retains the Explorer host through appearance changes and folding", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + const explorer = await ensureExplorerSidebar(page); + await openFilesPanel(page); + const host = await explorer.elementHandle(); + if (!host) throw new Error("Explorer must be mounted before changing appearance"); + const previousColor = await explorer.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + await cycleWorkspaceTheme(page); + await expect + .poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(previousColor); + expect(await host.evaluate((element) => element.isConnected)).toBe(true); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureExplorerSidebar(page); + expect(await host.evaluate((element) => element.isConnected)).toBe(true); + await page.setViewportSize({ width: 1280, height: 900 }); + await expect(explorer).toBeVisible(); + expect(await host.evaluate((element) => element.isConnected)).toBe(true); + } finally { + await workspace.cleanup(); + } +}); + +async function reorderCompactExplorerTabs(page: Parameters[0]) { + const explorer = explorerSidebar(page); + const files = explorer.getByTestId("workspace-tab-files"); + const changes = explorer.getByTestId("workspace-tab-changes_tree"); + const source = await changes.boundingBox(); + const target = await files.boundingBox(); + if (!source || !target) throw new Error("Explorer tabs must be visible to reorder"); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2); + await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 }); + await page.mouse.up(); + await expect + .poll(async () => { + const afterFiles = await files.boundingBox(); + const afterChanges = await changes.boundingBox(); + return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x); + }) + .toBe(true); +} + +test("reorders compact Explorer tabs with a pointer without changing selection", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await page.setViewportSize({ width: 390, height: 844 }); + await openFilesPanel(page); + await reorderCompactExplorerTabs(page); + await expect( + explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + } finally { + await workspace.cleanup(); + } +}); + +// Exercise appearance at its consumers while the real workspace navigator retains the panels. +test("updates populated code and diff appearance without losing the editor instance or draft", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" }); + const source = Array.from( + { length: 120 }, + (_, index) => `export const line${index} = "retained ${index}";`, + ).join("\n"); + try { + await writeFile(`${workspace.repoPath}/appearance.ts`, source); + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ codeFontSize: 13, theme: "light" }), + ); + localStorage.setItem( + "@paseo:changes-preferences", + JSON.stringify({ inlineDiff: true, layout: "unified" }), + ); + }); + await gotoWorkspace(page, workspace.workspaceId); + await openFilesPanel(page); + await openFileFromExplorer(page, "appearance.ts"); + const editor = page.getByTestId("file-source-editor").filter({ visible: true }); + const text = editor.locator(".cm-content"); + await expect(text).toBeVisible(); + await text.fill(`${source}\n// unsaved local edit`); + const instance = await editor.elementHandle(); + const scroller = editor.locator(".cm-scroller"); + await scroller.evaluate((node) => { + node.scrollTop = 400; + }); + const scrollBefore = await scroller.evaluate((node) => node.scrollTop); + await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click(); + const diff = explorerSidebar(page).getByTestId("git-diff-canvas"); + await expect(diff).toBeVisible(); + await page.keyboard.press("Control+,"); + await openSettingsSection(page, "appearance"); + await page.getByLabel("Code font size", { exact: true }).fill("19"); + await page.getByLabel("Code font size", { exact: true }).press("Tab"); + await clickSettingsBackToWorkspace(page); + await expect(text).toHaveCSS("font-size", "19px"); + expect(await instance!.evaluate((node) => node.isConnected)).toBe(true); + await expect(text).toContainText("unsaved local edit"); + expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore); + await expect(diff).toHaveCSS("font-size", "19px"); + await cycleWorkspaceTheme(page); + expect(await instance!.evaluate((node) => node.isConnected)).toBe(true); + await expect(text).toContainText("unsaved local edit"); + await dragTabIntoExplorer(page, "file_appearance.ts"); + await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible(); + const explorerEditor = await explorerSidebar(page) + .getByTestId("file-source-editor") + .elementHandle(); + await text.fill(`${source}\n// retained Explorer edit`); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureExplorerSidebar(page); + await page.setViewportSize({ width: 1280, height: 900 }); + expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true); + await expect(text).toContainText("retained Explorer edit"); + } finally { + await workspace.cleanup(); + } +}); + +test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({ + page, +}) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "explorer-chat-appearance-", + title: "Retained chat", + initialPrompt: "Show the retention marker", + featureValues: { + mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```", + }, + }); + const attaches: string[] = []; + page.on("websocket", (socket) => + socket.on("framesent", (frame) => { + if (typeof frame.payload !== "string") return; + try { + const message = JSON.parse(frame.payload).message; + if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId); + } catch { + /* Binary terminal traffic is not a JSON request. */ + } + }), + ); + try { + await openAgentRoute(page, workspace); + await ensureExplorerSidebar(page); + await dragTabIntoExplorer(page, `agent_${workspace.agentId}`); + const explorer = explorerSidebar(page); + const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true }); + await expect(paragraph).toBeVisible(); + const composer = explorer.getByRole("textbox", { name: "Message agent..." }); + await composer.fill("Unsubmitted Explorer draft"); + const composerNode = await composer.elementHandle(); + await page.keyboard.press("Control+,"); + await openSettingsSection(page, "appearance"); + await page.getByLabel("Content font size", { exact: true }).fill("20"); + await page.getByLabel("Content font size", { exact: true }).press("Tab"); + await clickSettingsBackToWorkspace(page); + await expect(paragraph).toHaveCSS("font-size", "20px"); + expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true); + await expect(composer).toHaveValue("Unsubmitted Explorer draft"); + const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color); + await cycleWorkspaceTheme(page); + await expect + .poll(() => paragraph.evaluate((node) => getComputedStyle(node).color)) + .not.toBe(previousColor); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await page + .getByRole("menuitem", { name: /^Terminal/ }) + .first() + .click(); + const terminal = explorer.getByTestId("terminal-surface"); + await expect(terminal).toBeVisible(); + await expect.poll(() => attaches.length).toBe(1); + const terminalNode = await terminal.elementHandle(); + await page.getByTestId("workspace-explorer-toggle").first().click(); + await ensureExplorerSidebar(page); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureExplorerSidebar(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await cycleWorkspaceTheme(page); + expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true); + expect(attaches).toHaveLength(1); + await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click(); + expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true); + await expect(composer).toHaveValue("Unsubmitted Explorer draft"); + await expect(paragraph).toHaveCSS("font-size", "20px"); + } finally { + await workspace.cleanup(); + } +}); + +test("compact file sheets cover Explorer and return to its retained file list", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" }); + try { + await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n"); + await gotoWorkspace(page, workspace.workspaceId); + await page.setViewportSize({ width: 390, height: 844 }); + const explorer = await ensureExplorerSidebar(page); + await openFilesPanel(page); + const host = await explorer.elementHandle(); + await openFileFromExplorer(page, "sheet-marker.txt"); + await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible(); + // A real click also proves the sheet draws above the retained Explorer overlay. + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0); + await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + expect(await host!.evaluate((node) => node.isConnected)).toBe(true); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index da2fd59d36..d662a2f345 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -445,11 +445,6 @@ const AgentStreamViewComponent = forwardRef console.log("DUO_INPUT", e.nativeEvent.pageX, e.nativeEvent.pageY)}> + {!isCompactLayout ? ( {themedSidebarChrome} ) : null} - {usesCompactExplorerHost ? ( - - - {children} - - - ) : ( - + + {children} - )} + ); @@ -634,15 +625,27 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon ); - const content = isCompactLayout ? ( - {surface} - ) : ( - surface + return ( + + {surface} + ); +} +function AppContainerLifetime({ + children, + compactGesturesEnabled, +}: { + children: ReactNode; + compactGesturesEnabled: boolean; +}) { return ( - {content} + + + {children} + + ); } diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 1a47093e2c..df16818239 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -10,6 +10,8 @@ const HOST_STACK_SCREEN_OPTIONS = { animation: "none" as const, }; +const SCREEN_OWNED_APPEARANCE = ["workspace/[workspaceId]/index"] as const; + const AGENT_SCREEN_OPTIONS = { gestureEnabled: false }; export default function HostRouteLayout() { @@ -34,7 +36,10 @@ function KnownHostRoute() { } const stack = ( - + diff --git a/packages/app/src/components/appearance-style-boundary.test.ts b/packages/app/src/components/appearance-style-boundary.test.ts index 58309ae3a5..5e73ce36f9 100644 --- a/packages/app/src/components/appearance-style-boundary.test.ts +++ b/packages/app/src/components/appearance-style-boundary.test.ts @@ -20,55 +20,53 @@ describe("appearanceStyleBoundaryKey", () => { // This is a placement contract against the production JSX, not a simulated native mount. // Native Gesture Handler/Fabric lifetime behavior is verified on the release app. describe("retained native gesture host appearance placement", () => { - it.each([ - ["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"], true], - [ - "../screens/workspace/workspace-screen.tsx", - ["CompactExplorerSidebar", "NativeExplorerSidebarDock"], - false, - ], - ] as const)("keeps native hosts outside appearance keys in %s", (path, hosts, hasWebKeys) => { - const source = ts.createSourceFile( - path, - readFileSync(new URL(path, import.meta.url), "utf8"), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX, - ); - let hostsFound = 0; - let webKeysFound = 0; - function visit(node: ts.Node) { - let name: string | undefined; - if (ts.isIdentifier(node)) name = node.text; - if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); - if (name && hosts.some((host) => host === name)) { - hostsFound++; - let webOnly = false; - let keyed = false; - for ( - let child = node, parent = node.parent; - parent; - child = parent, parent = parent.parent - ) { - if ( - ts.isIfStatement(parent) && - parent.expression.getText(source) === "isWeb" && - parent.thenStatement === child - ) - webOnly = true; - if ( - ts.isJsxElement(parent) && - parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" - ) - keyed = true; + it.each([["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"], true]] as const)( + "keeps native hosts outside appearance keys in %s", + (path, hosts, hasWebKeys) => { + const source = ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let hostsFound = 0; + let webKeysFound = 0; + function visit(node: ts.Node) { + let name: string | undefined; + if (ts.isIdentifier(node)) name = node.text; + if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); + if (name && hosts.some((host) => host === name)) { + hostsFound++; + let webOnly = false; + let keyed = false; + for ( + let child = node, parent = node.parent; + parent; + child = parent, parent = parent.parent + ) { + if ( + ts.isIfStatement(parent) && + parent.expression.getText(source) === "isWeb" && + parent.thenStatement === child + ) + webOnly = true; + if ( + ts.isJsxElement(parent) && + parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" + ) + keyed = true; + } + if (keyed && webOnly) webKeysFound++; + expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe( + false, + ); } - if (keyed && webOnly) webKeysFound++; - expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false); + ts.forEachChild(node, visit); } - ts.forEachChild(node, visit); - } - visit(source); - expect(hostsFound).toBeGreaterThan(0); - if (hasWebKeys) expect(webKeysFound).toBeGreaterThan(0); - }); + visit(source); + expect(hostsFound).toBeGreaterThan(0); + if (hasWebKeys) expect(webKeysFound).toBeGreaterThan(0); + }, + ); }); diff --git a/packages/app/src/components/compact-explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx deleted file mode 100644 index 9f025f07c2..0000000000 --- a/packages/app/src/components/compact-explorer-sidebar.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { View } from "react-native"; -import { Gesture } from "react-native-gesture-handler"; -import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { scheduleOnRN } from "react-native-worklets"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; -import { useIsMobilePanelActive } from "@/mobile-panels/provider"; -import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; -import { RetainedPanelActivity } from "@/components/retained-panel"; -import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; -import { - SIDEBAR_RESIZE_ACTIVATION_OFFSET, - SIDEBAR_RESIZE_FAIL_OFFSET, -} from "@/components/sidebar-resize-handle-layout"; -import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; - -interface ExplorerSidebarProps { - children: React.ReactNode; - surfaceColor?: string; -} - -function CompactExplorerSidebarBase({ children, surfaceColor }: ExplorerSidebarProps) { - const insets = useSafeAreaInsets(); - const isActive = useIsMobilePanelActive("file-explorer"); - const { gesture: closeGesture } = useCloseFileExplorerGesture(); - - const mobileSidebarStyle = useMemo( - () => [ - { - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, - backgroundColor: surfaceColor, - }, - ], - [insets.top, surfaceColor], - ); - - return ( - - - {children} - - - ); -} - -interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps { - persistenceKey: string; - containerWidth: number; - isOpen: boolean; -} - -function NativeExplorerSidebarDockBase({ - surfaceColor, - children, - persistenceKey, - containerWidth, - isOpen, -}: NativeExplorerSidebarDockProps) { - const insets = useSafeAreaInsets(); - const storedWidth = useWorkspaceLayoutStore( - (state) => state.explorerSidebarWidthByWorkspace[persistenceKey], - ); - const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); - const visibleWidth = resolveExplorerSidebarWidth({ - requestedWidth: storedWidth, - containerWidth, - }); - const resizeWidth = useSharedValue(visibleWidth); - const startWidth = useSharedValue(visibleWidth); - const [resizePressed, setResizePressed] = useState(false); - useEffect(() => { - resizeWidth.value = visibleWidth; - }, [resizeWidth, visibleWidth]); - - const showResizeGrip = useCallback(() => setResizePressed(true), []); - const hideResizeGrip = useCallback(() => setResizePressed(false), []); - const commitWidth = useCallback( - (width: number) => resizeExplorerSidebar(persistenceKey, width), - [persistenceKey, resizeExplorerSidebar], - ); - const resizeGesture = useMemo( - () => - Gesture.Pan() - .enabled(true) - .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) - .onBegin(() => scheduleOnRN(showResizeGrip)) - .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) - .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) - .onStart((event) => { - startWidth.value = visibleWidth + event.translationX; - resizeWidth.value = visibleWidth; - }) - .onUpdate((event) => { - resizeWidth.value = resolveExplorerSidebarWidth({ - requestedWidth: startWidth.value - event.translationX, - containerWidth, - }); - }) - .onEnd(() => runOnJS(commitWidth)(resizeWidth.value)) - .onFinalize(() => scheduleOnRN(hideResizeGrip)), - [ - commitWidth, - containerWidth, - hideResizeGrip, - resizeWidth, - showResizeGrip, - startWidth, - visibleWidth, - ], - ); - const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value })); - const dockStyle = useMemo( - () => [ - { flexShrink: 0, minHeight: 0 }, - { - display: isOpen ? ("flex" as const) : ("none" as const), - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, - backgroundColor: surfaceColor, - }, - animatedWidthStyle, - ], - [animatedWidthStyle, insets.top, isOpen, surfaceColor], - ); - return ( - - - - - {children} - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - nativeDockContent: { - flex: 1, - minHeight: 0, - borderLeftWidth: 1, - borderLeftColor: theme.colors.border, - }, -})); - -export const CompactExplorerSidebar = withUnistyles(CompactExplorerSidebarBase, (theme) => ({ - surfaceColor: theme.colors.surfaceSidebar, -})); -export const NativeExplorerSidebarDock = withUnistyles(NativeExplorerSidebarDockBase, (theme) => ({ - surfaceColor: theme.colors.surfaceSidebar, -})); diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index 6ed2bddcbe..cbc161ae66 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -52,8 +52,15 @@ export function CompactHeader({ }: CompactHeaderProps) { const insets = useSafeAreaInsets(); const rootStyle = useMemo( - () => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }], - [insets.top], + () => [ + styles.root, + { + paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, + marginLeft: insets.left, + marginRight: insets.right, + }, + ], + [insets.top, insets.left, insets.right], ); return ( diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index c6d95f40b7..52a51a2a3a 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,3 +1,4 @@ +import { useWorkspaceTabDrag } from "./workspace-tab-drag-context"; import { Fragment, memo, @@ -11,35 +12,14 @@ import { type SetStateAction, } from "react"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { - DndContext, - DragOverlay, - KeyboardSensor, - PointerSensor, - closestCenter, - pointerWithin, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragMoveEvent, - type DragOverEvent, - type DragStartEvent, -} from "@dnd-kit/core"; -import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; -import { View, Text, type LayoutChangeEvent } from "react-native"; +import { View, type LayoutChangeEvent } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, type SharedValue, } from "react-native-reanimated"; -import { useTranslation } from "react-i18next"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; -import { - resolveExplorerSidebarDockSizes, - resolveExplorerSidebarWidth, -} from "@/components/explorer-sidebar-layout"; import { RetainedPanel } from "@/components/retained-panel"; import { hasMultipleVisiblePanes, @@ -48,41 +28,26 @@ import { } from "@/components/split-container-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { - removeWindowChromeCorner, WindowChromeRegion, WindowChromeSafeArea, useWindowChromeCorners, + removeWindowChromeCorner, type WindowChromeCorners, } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { - computeTabDropPreview, - type TabDropPreview, -} from "@/components/split-container-tab-drop-preview"; -import { - SplitDropZone, - resolveSplitDropPosition, - type SplitDropZoneHover, -} from "@/components/split-drop-zone"; -import { - deriveWorkspacePaneState, - getWorkspacePaneDescriptors, -} from "@/screens/workspace/workspace-pane-state"; +import { type TabDropPreview } from "@/components/split-container-tab-drop-preview"; +import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone"; +import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; -import { - WorkspaceTabPresentationResolver, - WorkspaceTabIcon, -} from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { + collectAllPanes, createDefaultLayout, - findPaneById, useWorkspaceLayoutStore, type SplitNode, type SplitPane, @@ -92,12 +57,10 @@ import type { WorkspaceTab } from "@/workspace-tabs/model"; import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { isNative } from "@/constants/platform"; -import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; interface SplitContainerProps { layout: WorkspaceLayout; renderMainHeader?: () => ReactNode; - renderExplorerSidebarHeaderAction?: () => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -140,41 +103,6 @@ interface SplitContainerProps { onExitFocusMode: () => void; } -interface WorkspaceTabDragData { - kind: "workspace-tab"; - paneId: string; - tabId: string; -} - -interface SplitPaneDropData { - kind: "split-pane-drop"; - paneId: string; -} - -const EMPTY_SPLIT_NODES: SplitNode[] = []; -const EMPTY_SPLIT_SIZES: number[] = []; -const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar"; - -function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { - return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; -} - -function isSplitPaneDropData(data: unknown): data is SplitPaneDropData { - return ( - typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop" - ); -} - -function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined { - return isWorkspaceTabDragData(data) ? data : undefined; -} - -function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined { - if (isWorkspaceTabDragData(data)) return data; - if (isSplitPaneDropData(data)) return data; - return undefined; -} - interface SplitNodeViewProps extends Omit< SplitContainerProps, "layout" | "onMoveTabToPane" | "onSelectTabInPane" @@ -212,105 +140,12 @@ interface SplitPaneViewProps extends Omit< tabDropPreview: TabDropPreview | null; } -interface DragMoveRects { - translatedRect: { left: number; top: number; width: number; height: number }; - overRect: { left: number; top: number; width: number; height: number }; -} - -function resolveDragMoveRects( - event: Pick | Pick, -): DragMoveRects | null { - const translatedRect = event.active.rect.current.translated; - const overRect = event.over?.rect; - if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { - return null; - } - return { translatedRect, overRect }; -} - -function computeTabOverDropPreview(input: { - activeData: WorkspaceTabDragData; - overData: WorkspaceTabDragData; - rects: DragMoveRects; - panesById: Map; - uiTabs: WorkspaceTab[]; -}): TabDropPreview | null { - const { activeData, overData, rects, panesById, uiTabs } = input; - const targetPane = panesById.get(overData.paneId) ?? null; - if (!targetPane) { - return null; - } - const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); - return computeTabDropPreview({ - activePaneId: activeData.paneId, - activeTabId: activeData.tabId, - overPaneId: overData.paneId, - overTabId: overData.tabId, - targetTabs, - activeRect: { - left: rects.translatedRect.left, - width: rects.translatedRect.width, - }, - overRect: { - left: rects.overRect.left, - width: rects.overRect.width, - }, - }); -} - -function computePaneOverDropPreview(input: { - overData: SplitPaneDropData; - rects: DragMoveRects; -}): SplitDropZoneHover | null { - const { overData, rects } = input; - const centerX = rects.translatedRect.left + rects.translatedRect.width / 2; - const centerY = rects.translatedRect.top + rects.translatedRect.height / 2; - const relativeX = centerX - rects.overRect.left; - const relativeY = centerY - rects.overRect.top; - if ( - Number.isNaN(relativeX) || - Number.isNaN(relativeY) || - relativeX < 0 || - relativeX > rects.overRect.width || - relativeY < 0 || - relativeY > rects.overRect.height - ) { - return null; - } - return { - paneId: overData.paneId, - position: resolveSplitDropPosition({ - width: rects.overRect.width, - height: rects.overRect.height, - x: relativeX, - y: relativeY, - }), - }; -} - -const dropCollisionDetection: CollisionDetection = (args) => { - const pointerHits = pointerWithin(args); - const tabHits = pointerHits.filter( - (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab", - ); - if (tabHits.length > 0) { - return tabHits; - } - - const paneHits = pointerHits.filter( - (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop", - ); - if (paneHits.length > 0) { - return paneHits; - } - - return closestCenter(args); -}; +const EMPTY_SPLIT_NODES: SplitNode[] = []; +const EMPTY_SPLIT_SIZES: number[] = []; export function SplitContainer({ layout, renderMainHeader, - renderExplorerSidebarHeaderAction, workspaceKey, normalizedServerId, normalizedWorkspaceId, @@ -335,8 +170,6 @@ export function SplitContainer({ onFocusPane, onSplitPane, onSplitPaneEmpty, - onMoveTabToPane, - onSelectTabInPane, onResizeSplit, onReorderTabsInPane, focusModeEnabled, @@ -344,9 +177,7 @@ export function SplitContainer({ }: SplitContainerProps) { const inheritedWindowChromeCorners = useWindowChromeCorners(); const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; - const [activeDragTabId, setActiveDragTabId] = useState(null); - const [dropPreview, setDropPreview] = useState(null); - const [tabDropPreview, setTabDropPreview] = useState(null); + const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag(); const [maximizedPane, setMaximizedPane] = useState<{ workspaceKey: string; paneId: string; @@ -357,22 +188,6 @@ export function SplitContainer({ (state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, ); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); - const explorerSidebarPane = useMemo( - () => findPaneById(layout.root, explorerSidebarPaneId), - [layout.root, explorerSidebarPaneId], - ); const mainRoot = useMemo( () => removePaneFromSplitTree(layout.root, explorerSidebarPaneId), [layout.root, explorerSidebarPaneId], @@ -408,434 +223,68 @@ export function SplitContainer({ }), [focusModeEnabled, layout.focusedPaneId, mainRoot], ); - const storedExplorerSidebarWidth = useWorkspaceLayoutStore( - (state) => state.explorerSidebarWidthByWorkspace[workspaceKey], - ); - const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); - const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0); - const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState( - null, - ); - const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth; - const explorerSidebarWidth = resolveExplorerSidebarWidth({ - requestedWidth: requestedExplorerSidebarWidth, - containerWidth: workspaceShellWidth, - }); - const explorerSidebarDockSizes = useMemo( - () => - resolveExplorerSidebarDockSizes({ - requestedWidth: requestedExplorerSidebarWidth, - containerWidth: workspaceShellWidth, - }), - [requestedExplorerSidebarWidth, workspaceShellWidth], - ); - const renderExplorerSidebarDock = Boolean( - !focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true, - ); - const mainColumnWindowChromeCorners = renderExplorerSidebarDock - ? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right") - : inheritedWindowChromeCorners; + const explorerPane = explorerSidebarPaneId + ? collectAllPanes(layout.root).find((pane) => pane.id === explorerSidebarPaneId) + : null; + const mainColumnWindowChromeCorners = + !focusModeEnabled && explorerPane && !explorerPane.hidden + ? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right") + : inheritedWindowChromeCorners; const mainColumnStyle = styles.mainColumn; - const explorerSidebarDockStyle = useMemo( - () => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })], - [explorerSidebarWidth], - ); - const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => { - const nextWidth = event.nativeEvent.layout.width; - setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth)); - }, []); - const previewExplorerSidebarResize = useCallback( - (_groupId: string, sizes: number[]) => { - const nextRatio = sizes[1]; - if (nextRatio !== undefined) { - setPreviewExplorerSidebarWidth( - resolveExplorerSidebarWidth({ - requestedWidth: nextRatio * workspaceShellWidth, - containerWidth: workspaceShellWidth, - }), - ); - } - }, - [workspaceShellWidth], - ); - const commitExplorerSidebarResize = useCallback( - (_groupId: string, sizes: number[]) => { - setPreviewExplorerSidebarWidth(null); - const nextRatio = sizes[1]; - if (nextRatio !== undefined) { - resizeExplorerSidebar( - workspaceKey, - resolveExplorerSidebarWidth({ - requestedWidth: nextRatio * workspaceShellWidth, - containerWidth: workspaceShellWidth, - }), - ); - } - }, - [resizeExplorerSidebar, workspaceKey, workspaceShellWidth], - ); const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); - const handleDragStart = useCallback((event: DragStartEvent) => { - const data = asWorkspaceTabDragData(event.active.data.current); - if (!data) { - setActiveDragTabId(null); - setDropPreview(null); - setTabDropPreview(null); - return; - } - setActiveDragTabId(data.tabId); - }, []); - - const handleDragCancel = useCallback(() => { - setActiveDragTabId(null); - setDropPreview(null); - setTabDropPreview(null); - }, []); - - const updateDropPreview = useCallback( - (event: Pick | Pick) => { - const activeData = asWorkspaceTabDragData(event.active.data.current); - const overData = asDragOverData(event.over?.data.current); - - if (activeData?.kind !== "workspace-tab") { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - const rects = resolveDragMoveRects(event); - if (!rects) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null; - const destinationPaneId = - overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop" - ? overData.paneId - : null; - const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main"; - if ( - !activeTab || - !panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost) - ) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - if (overData?.kind === "workspace-tab") { - const preview = computeTabOverDropPreview({ - activeData, - overData, - rects, - panesById, - uiTabs, - }); - setDropPreview(null); - setTabDropPreview(preview); - return; - } - - setTabDropPreview(null); - if (overData?.kind !== "split-pane-drop") { - setDropPreview(null); - return; - } - - setDropPreview(computePaneOverDropPreview({ overData, rects })); - }, - [normalizedServerId, panesById, explorerSidebarPaneId, uiTabs], - ); - - const applyTabDropEnd = useCallback( - (input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => { - const { activeData, overData } = input; - const sourcePane = panesById.get(activeData.paneId) ?? null; - const targetPane = panesById.get(overData.paneId) ?? null; - if (!sourcePane || !targetPane) { - return; - } - - const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); - const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); - const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); - const resolvedTabDropPreview = - tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null; - if (sourceIndex < 0 || !resolvedTabDropPreview) { - return; - } - - if (activeData.paneId === overData.paneId) { - if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { - const nextTabs = arrayMove( - sourceTabs, - sourceIndex, - resolvedTabDropPreview.insertionIndex, - ); - onReorderTabsInPane( - activeData.paneId, - nextTabs.map((tab) => tab.tabId), - ); - } - return; - } - - const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); - nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId); - onMoveTabToPane(activeData.tabId, overData.paneId); - onReorderTabsInPane(overData.paneId, nextTargetTabIds); - }, - [onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs], - ); - - const applyPaneDropEnd = useCallback( - (input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => { - const { activeData, overData } = input; - if (dropPreview?.paneId !== overData.paneId) { - return; - } - if (dropPreview.position === "center") { - if (activeData.paneId !== overData.paneId) { - onMoveTabToPane(activeData.tabId, overData.paneId); - } - return; - } - onSplitPane({ - tabId: activeData.tabId, - targetPaneId: overData.paneId, - position: dropPreview.position, - }); - }, - [dropPreview, onMoveTabToPane, onSplitPane], - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const activeData = asWorkspaceTabDragData(event.active.data.current); - const overData = asDragOverData(event.over?.data.current); - - setActiveDragTabId(null); - - if (activeData?.kind === "workspace-tab" && event.over) { - if (overData?.kind === "workspace-tab") { - applyTabDropEnd({ activeData, overData }); - } else if (overData?.kind === "split-pane-drop") { - applyPaneDropEnd({ activeData, overData }); - } - } - - setDropPreview(null); - setTabDropPreview(null); - }, - [applyTabDropEnd, applyPaneDropEnd], - ); - return ( - - - - - {renderMainHeader?.()} - {splitRoot.usesFallbackStrip && } - {renderRoot ? ( - - ) : null} - - - {renderExplorerSidebarDock && explorerSidebarPane ? ( - <> - + + + {renderMainHeader?.()} + {splitRoot.usesFallbackStrip && } + {renderRoot ? ( + - - - - - ) : null} - - - {activeDragTabId ? ( - - ) : null} - - - - ); -} - -function DragOverlayTabChip({ - tabId, - uiTabs, - normalizedServerId, - normalizedWorkspaceId, -}: { - tabId: string; - uiTabs: WorkspaceTab[]; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const tab = uiTabs.find((t) => t.tabId === tabId); - const descriptor = useMemo( - () => - tab - ? { - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - } - : null, - [tab], - ); - if (!descriptor) { - return null; - } - return ( - - ); -} - -function DragOverlayTabChipInner({ - tab, - normalizedServerId, - normalizedWorkspaceId, -}: { - tab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const { t } = useTranslation(); - const { theme } = useUnistyles(); - - const chipStyle = useMemo( - () => [ - styles.dragOverlayChip, - { - backgroundColor: theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - ], - [theme.colors.surface1, theme.colors.borderAccent], - ); - const chipLabelStyle = useMemo( - () => [styles.dragOverlayLabel, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - - return ( - - {(presentation) => { - const label = - presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; - - return ( - - - - {label} - + ) : null} - ); - }} - + + + ); } @@ -1317,21 +766,6 @@ const SplitPaneView = memo(function SplitPaneView({ ); }); -function collectPanesById(node: SplitNode): Map { - const next = new Map(); - function visit(current: SplitNode) { - if (current.kind === "pane") { - next.set(current.pane.id, current.pane); - return; - } - for (const child of current.group.children) { - visit(child); - } - } - visit(node); - return next; -} - function getNodeKey(node: SplitNode): string { if (node.kind === "pane") { return node.pane.id; @@ -1396,12 +830,6 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, minHeight: 0, }, - explorerSidebarDock: { - flexShrink: 0, - minWidth: 240, - minHeight: 0, - backgroundColor: theme.colors.surfaceSidebar, - }, group: { flex: 1, minWidth: 0, @@ -1436,18 +864,4 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, minHeight: 0, }, - dragOverlayChip: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - maxWidth: 200, - }, - dragOverlayLabel: { - fontSize: theme.fontSize.base, - flexShrink: 1, - }, })); diff --git a/packages/app/src/components/workspace-tab-drag-context.ts b/packages/app/src/components/workspace-tab-drag-context.ts new file mode 100644 index 0000000000..543997af13 --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; +import type { TabDropPreview } from "./split-container-tab-drop-preview"; +import type { SplitDropZoneHover } from "./split-drop-zone"; +export const WorkspaceTabDragContext = createContext<{ + activeDragTabId: string | null; + dropPreview: SplitDropZoneHover | null; + tabDropPreview: TabDropPreview | null; + externalDndContext: boolean; +}>({ activeDragTabId: null, dropPreview: null, tabDropPreview: null, externalDndContext: false }); +export function useWorkspaceTabDrag() { + return useContext(WorkspaceTabDragContext); +} diff --git a/packages/app/src/components/workspace-tab-drag-policy.ts b/packages/app/src/components/workspace-tab-drag-policy.ts new file mode 100644 index 0000000000..e2ac7a2dee --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-policy.ts @@ -0,0 +1,9 @@ +export function acceptsWorkspaceTabDrop( + allowCrossPane: boolean, + sourcePaneId: string, + destination: { kind: "workspace-tab" | "split-pane-drop"; paneId: string }, +): boolean { + return ( + allowCrossPane || (destination.kind === "workspace-tab" && sourcePaneId === destination.paneId) + ); +} diff --git a/packages/app/src/components/workspace-tab-drag-scope.tsx b/packages/app/src/components/workspace-tab-drag-scope.tsx new file mode 100644 index 0000000000..4c3f077632 --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-scope.tsx @@ -0,0 +1,5 @@ +import type { ComponentProps } from "react"; +import type { WorkspaceTabDragScope as WebScope } from "./workspace-tab-drag-scope.web"; +export function WorkspaceTabDragScope({ children }: ComponentProps) { + return children; +} diff --git a/packages/app/src/components/workspace-tab-drag-scope.web.tsx b/packages/app/src/components/workspace-tab-drag-scope.web.tsx new file mode 100644 index 0000000000..3ff9bc3b10 --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-scope.web.tsx @@ -0,0 +1,494 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { View, Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + pointerWithin, + useSensor, + useSensors, + type CollisionDetection, + type DragEndEvent, + type DragMoveEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { computeTabDropPreview, type TabDropPreview } from "./split-container-tab-drop-preview"; +import { resolveSplitDropPosition, type SplitDropZoneHover } from "./split-drop-zone"; +import { getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabIcon, +} from "@/screens/workspace/workspace-tab-presentation"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + collectAllPanes, + createWorkspaceLayoutWithExplorerSidebar, + type WorkspaceLayout, + type SplitPane, +} from "@/stores/workspace-layout-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; +import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; +import { acceptsWorkspaceTabDrop } from "./workspace-tab-drag-policy"; +import { WorkspaceTabDragContext } from "./workspace-tab-drag-context"; +interface WorkspaceTabDragData { + kind: "workspace-tab"; + paneId: string; + tabId: string; +} + +interface SplitPaneDropData { + kind: "split-pane-drop"; + paneId: string; +} + +function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { + return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; +} + +function isSplitPaneDropData(data: unknown): data is SplitPaneDropData { + return ( + typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop" + ); +} + +function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined { + return isWorkspaceTabDragData(data) ? data : undefined; +} + +function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined { + if (isWorkspaceTabDragData(data)) return data; + if (isSplitPaneDropData(data)) return data; + return undefined; +} + +interface DragMoveRects { + translatedRect: { left: number; top: number; width: number; height: number }; + overRect: { left: number; top: number; width: number; height: number }; +} + +function resolveDragMoveRects( + event: Pick | Pick, +): DragMoveRects | null { + const translatedRect = event.active.rect.current.translated; + const overRect = event.over?.rect; + if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { + return null; + } + return { translatedRect, overRect }; +} + +function computeTabOverDropPreview(input: { + activeData: WorkspaceTabDragData; + overData: WorkspaceTabDragData; + rects: DragMoveRects; + panesById: Map; + uiTabs: WorkspaceTab[]; +}): TabDropPreview | null { + const { activeData, overData, rects, panesById, uiTabs } = input; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!targetPane) { + return null; + } + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); + return computeTabDropPreview({ + activePaneId: activeData.paneId, + activeTabId: activeData.tabId, + overPaneId: overData.paneId, + overTabId: overData.tabId, + targetTabs, + activeRect: { + left: rects.translatedRect.left, + width: rects.translatedRect.width, + }, + overRect: { + left: rects.overRect.left, + width: rects.overRect.width, + }, + }); +} + +function computePaneOverDropPreview(input: { + overData: SplitPaneDropData; + rects: DragMoveRects; +}): SplitDropZoneHover | null { + const { overData, rects } = input; + const centerX = rects.translatedRect.left + rects.translatedRect.width / 2; + const centerY = rects.translatedRect.top + rects.translatedRect.height / 2; + const relativeX = centerX - rects.overRect.left; + const relativeY = centerY - rects.overRect.top; + if ( + Number.isNaN(relativeX) || + Number.isNaN(relativeY) || + relativeX < 0 || + relativeX > rects.overRect.width || + relativeY < 0 || + relativeY > rects.overRect.height + ) { + return null; + } + return { + paneId: overData.paneId, + position: resolveSplitDropPosition({ + width: rects.overRect.width, + height: rects.overRect.height, + x: relativeX, + y: relativeY, + }), + }; +} + +const dropCollisionDetection: CollisionDetection = (args) => { + const pointerHits = pointerWithin(args); + const tabHits = pointerHits.filter( + (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab", + ); + if (tabHits.length > 0) { + return tabHits; + } + + const paneHits = pointerHits.filter( + (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop", + ); + if (paneHits.length > 0) { + return paneHits; + } + + return closestCenter(args); +}; + +const EMPTY_LAYOUT = createWorkspaceLayoutWithExplorerSidebar(); + +interface WorkspaceTabDragScopeProps { + children: ReactNode; + layout: WorkspaceLayout | null; + explorerSidebarPaneId: string | null; + allowCrossPane: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + uiTabs: WorkspaceTab[]; + onMoveTabToPane: (tabId: string, paneId: string) => void; + onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; + onSplitPane: (input: { + tabId: string; + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + }) => void; +} +export function WorkspaceTabDragScope({ + children, + layout, + explorerSidebarPaneId, + allowCrossPane, + normalizedServerId, + normalizedWorkspaceId, + uiTabs, + onMoveTabToPane, + onReorderTabsInPane, + onSplitPane, +}: WorkspaceTabDragScopeProps) { + const [activeDragTabId, setActiveDragTabId] = useState(null); + const [dropPreview, setDropPreview] = useState(null); + const [tabDropPreview, setTabDropPreview] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const panesById = useMemo( + () => new Map(collectAllPanes((layout ?? EMPTY_LAYOUT).root).map((pane) => [pane.id, pane])), + [layout], + ); + const handleDragStart = useCallback((event: DragStartEvent) => { + const data = asWorkspaceTabDragData(event.active.data.current); + if (!data) { + setActiveDragTabId(null); + setDropPreview(null); + setTabDropPreview(null); + return; + } + setActiveDragTabId(data.tabId); + }, []); + + const handleDragCancel = useCallback(() => { + setActiveDragTabId(null); + setDropPreview(null); + setTabDropPreview(null); + }, []); + + const updateDropPreview = useCallback( + (event: Pick | Pick) => { + const activeData = asWorkspaceTabDragData(event.active.data.current); + const overData = asDragOverData(event.over?.data.current); + + if ( + !activeData || + !overData || + !acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData) + ) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + + const rects = resolveDragMoveRects(event); + if (!rects) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + + const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null; + const destinationPaneId = + overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop" + ? overData.paneId + : null; + const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main"; + if ( + !activeTab || + !panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost) + ) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + + if (overData?.kind === "workspace-tab") { + const preview = computeTabOverDropPreview({ + activeData, + overData, + rects, + panesById, + uiTabs, + }); + setDropPreview(null); + setTabDropPreview(preview); + return; + } + + setTabDropPreview(null); + if (overData?.kind !== "split-pane-drop") { + setDropPreview(null); + return; + } + + setDropPreview(computePaneOverDropPreview({ overData, rects })); + }, + [allowCrossPane, normalizedServerId, panesById, explorerSidebarPaneId, uiTabs], + ); + + const applyTabDropEnd = useCallback( + (input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => { + const { activeData, overData } = input; + const sourcePane = panesById.get(activeData.paneId) ?? null; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!sourcePane || !targetPane) { + return; + } + + const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); + const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); + const resolvedTabDropPreview = + tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null; + if (sourceIndex < 0 || !resolvedTabDropPreview) { + return; + } + + if (activeData.paneId === overData.paneId) { + if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { + const nextTabs = arrayMove( + sourceTabs, + sourceIndex, + resolvedTabDropPreview.insertionIndex, + ); + onReorderTabsInPane( + activeData.paneId, + nextTabs.map((tab) => tab.tabId), + ); + } + return; + } + + const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); + nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId); + onMoveTabToPane(activeData.tabId, overData.paneId); + onReorderTabsInPane(overData.paneId, nextTargetTabIds); + }, + [onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs], + ); + + const applyPaneDropEnd = useCallback( + (input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => { + const { activeData, overData } = input; + if (dropPreview?.paneId !== overData.paneId) { + return; + } + if (dropPreview.position === "center") { + if (activeData.paneId !== overData.paneId) { + onMoveTabToPane(activeData.tabId, overData.paneId); + } + return; + } + onSplitPane({ + tabId: activeData.tabId, + targetPaneId: overData.paneId, + position: dropPreview.position, + }); + }, + [dropPreview, onMoveTabToPane, onSplitPane], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const activeData = asWorkspaceTabDragData(event.active.data.current); + const overData = asDragOverData(event.over?.data.current); + + setActiveDragTabId(null); + + if ( + activeData && + overData && + event.over && + acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData) + ) { + if (overData?.kind === "workspace-tab") { + applyTabDropEnd({ activeData, overData }); + } else if (overData?.kind === "split-pane-drop") { + applyPaneDropEnd({ activeData, overData }); + } + } + + setDropPreview(null); + setTabDropPreview(null); + }, + [allowCrossPane, applyTabDropEnd, applyPaneDropEnd], + ); + + const context = useMemo( + () => ({ activeDragTabId, dropPreview, tabDropPreview, externalDndContext: true }), + [activeDragTabId, dropPreview, tabDropPreview], + ); + return ( + + {children} + + {activeDragTabId ? ( + + ) : null} + + + ); +} +function DragOverlayTabChip({ + tabId, + uiTabs, + normalizedServerId, + normalizedWorkspaceId, +}: { + tabId: string; + uiTabs: WorkspaceTab[]; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const tab = uiTabs.find((t) => t.tabId === tabId); + const descriptor = useMemo( + () => + tab + ? { + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + } + : null, + [tab], + ); + if (!descriptor) { + return null; + } + return ( + + ); +} + +function DragOverlayTabChipInner({ + tab, + normalizedServerId, + normalizedWorkspaceId, +}: { + tab: WorkspaceTabDescriptor; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const { t } = useTranslation(); + return ( + + {(presentation) => { + const label = + presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; + + return ( + + + + {label} + + + ); + }} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + dragOverlayChip: { + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.borderAccent, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + maxWidth: 200, + }, + dragOverlayLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + flexShrink: 1, + }, +})); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 27ca8b8af8..d00b6f7790 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -415,11 +415,10 @@ export function WorkspaceDraftAgentTab({ openWorkspaceChanges({ isCompact: isCompactFormFactor, workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }), - checkout: { serverId, cwd: composerState.workingDir, isGit: true }, preferences: openInSidePane, }); }, - [composerState.workingDir, isCompactFormFactor, openInSidePane, serverId, workspaceId], + [isCompactFormFactor, openInSidePane, serverId, workspaceId], ); const { diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 8e43931677..04707d561b 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -3,7 +3,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { Text, View } from "react-native"; -import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; @@ -31,6 +31,25 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import type { Theme } from "@/styles/theme"; +// CodeMirror and native source rendering consume appearance as props. Subscribe at the +// rendering leaf so theme updates reconfigure it without replacing the editor/model. +const sourceAppearance = (theme: Theme) => ({ + theme: { + colorScheme: theme.colorScheme, + background: theme.colors.surface0, + foreground: theme.colors.foreground, + cursor: theme.colors.terminal.cursor, + foregroundMuted: theme.colors.foregroundMuted, + border: theme.colors.border, + selection: theme.colors.terminal.selectionBackground, + monoFont: theme.fontFamily.mono, + codeFontSize: theme.fontSize.code, + syntax: theme.colors.syntax, + }, +}); +const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance); +const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance); + const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, @@ -81,31 +100,14 @@ function ReadonlySource({ location: WorkspaceFileLocation; navigationRevision: number; }) { - const theme = UnistylesRuntime.getTheme(); const { t } = useTranslation(); - const visualTheme = useMemo( - () => ({ - colorScheme: theme.colorScheme, - background: theme.colors.surface0, - foreground: theme.colors.foreground, - cursor: theme.colors.terminal.cursor, - foregroundMuted: theme.colors.foregroundMuted, - border: theme.colors.border, - selection: theme.colors.terminal.selectionBackground, - monoFont: theme.fontFamily.mono, - codeFontSize: theme.fontSize.code, - syntax: theme.colors.syntax, - }), - [theme], - ); return ( - ); @@ -511,34 +513,6 @@ function EditableFilePane({ const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave }); - const theme = UnistylesRuntime.getTheme(); - const visualTheme = useMemo( - () => ({ - colorScheme: theme.colorScheme, - background: theme.colors.surface0, - foreground: theme.colors.foreground, - cursor: theme.colors.terminal.cursor, - foregroundMuted: theme.colors.foregroundMuted, - border: theme.colors.border, - selection: theme.colors.terminal.selectionBackground, - monoFont: theme.fontFamily.mono, - codeFontSize: theme.fontSize.code, - syntax: theme.colors.syntax, - }), - [ - theme.colors.border, - theme.colors.foreground, - theme.colors.foregroundMuted, - theme.colors.surface0, - theme.colors.syntax, - theme.colors.terminal.cursor, - theme.colors.terminal.selectionBackground, - theme.colorScheme, - theme.fontFamily.mono, - theme.fontSize.code, - ], - ); - useEffect(() => () => model.dispose(), [model]); const handleReload = useCallback(() => { @@ -592,13 +566,12 @@ function EditableFilePane({ onModeChange={onModeChange} /> {showSource ? ( - diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 6bafd3eee3..22a5c2df05 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1427,10 +1427,9 @@ function useDiffTabNavigation({ openWorkspacePullRequest({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: { serverId, cwd, isGit: true }, destination: pullRequestOpenLocation, }); - }, [cwd, isMobile, persistenceKey, pullRequestOpenLocation, serverId]); + }, [isMobile, persistenceKey, pullRequestOpenLocation]); return { openDiff, openCommit, diff --git a/packages/app/src/git/pull-request-connection.tsx b/packages/app/src/git/pull-request-connection.tsx new file mode 100644 index 0000000000..1f407cb006 --- /dev/null +++ b/packages/app/src/git/pull-request-connection.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +export interface PullRequestConnection { + client: Pick | null; + isConnected: boolean; +} +const ConnectionContext = createContext<((serverId: string) => PullRequestConnection) | null>(null); + +/** Supplies the PR read connection, shared by discovery, rail status and timeline content. */ +export function PullRequestConnectionProvider({ + connection, + children, +}: { + connection: (serverId: string) => PullRequestConnection; + children: ReactNode; +}) { + return {children}; +} + +export function usePullRequestConnection(serverId: string): PullRequestConnection { + const supplied = useContext(ConnectionContext); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + return supplied ? supplied(serverId) : { client, isConnected }; +} diff --git a/packages/app/src/git/pull-request-panel/use-data.test.ts b/packages/app/src/git/pull-request-panel/use-data.test.ts index 0e8a7edd97..e40b2889b5 100644 --- a/packages/app/src/git/pull-request-panel/use-data.test.ts +++ b/packages/app/src/git/pull-request-panel/use-data.test.ts @@ -406,7 +406,7 @@ describe("selectPrPaneState", () => { expect(state.data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null }); }); - it("returns null data when the consumer disabled timeline rendering", () => { + it("keeps cached content when retained activity disables timeline demand", () => { const state = selectPrPaneState({ ...baseSelectInput, status: prStatus(), @@ -414,7 +414,7 @@ describe("selectPrPaneState", () => { timelinePayload: timelinePayload(), }); - expect(state.data).toBeNull(); + expect(state.data).not.toBeNull(); expect(state.prNumber).toBe(42); }); diff --git a/packages/app/src/git/pull-request-panel/use-data.ts b/packages/app/src/git/pull-request-panel/use-data.ts index f43394cf7e..d3e8beb3ec 100644 --- a/packages/app/src/git/pull-request-panel/use-data.ts +++ b/packages/app/src/git/pull-request-panel/use-data.ts @@ -1,7 +1,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { usePullRequestConnection } from "@/git/pull-request-connection"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, @@ -20,7 +20,7 @@ type PullRequestTimeline = PullRequestTimelineResponse["payload"]; export interface UsePrPaneDataOptions { serverId: string; cwd: string; - enabled?: boolean; + enabled: boolean; timelineEnabled?: boolean; } @@ -166,7 +166,7 @@ export interface SelectPrPaneStateInput { export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult { const identity = extractPrRepoIdentity(input.status); const data = - identity.prNumber === null || !input.timelineEnabled + identity.prNumber === null ? null : mapPrPaneData(input.status, input.timelinePayload, undefined, input.forge ?? "github"); const statusRefreshing = input.statusIsFetching && !input.statusIsLoading; @@ -198,12 +198,11 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR export function usePrPaneData({ serverId, cwd, - enabled = true, + enabled, timelineEnabled = enabled, }: UsePrPaneDataOptions): UsePrPaneDataResult { const { t } = useTranslation(); - const daemonClient = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); + const { client: daemonClient, isConnected } = usePullRequestConnection(serverId); const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); const status = checkoutPrStatus.status; const identity = extractPrRepoIdentity(status); diff --git a/packages/app/src/git/use-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts index e8e4bf2b14..bd36543977 100644 --- a/packages/app/src/git/use-pr-status-query.ts +++ b/packages/app/src/git/use-pr-status-query.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { usePullRequestConnection } from "@/git/pull-request-connection"; import { checkoutPrStatusQueryKey } from "@/git/query-keys"; import { normalizeForge } from "@/git/forge"; import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint"; @@ -25,8 +25,7 @@ export function useCheckoutPrStatusQuery({ enabled = true, }: UseCheckoutPrStatusQueryOptions) { const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); + const { client, isConnected } = usePullRequestConnection(serverId); const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), @@ -67,8 +66,7 @@ export function useWorkspacePrHint({ enabled = true, }: UseCheckoutPrStatusQueryOptions): PrHint | null { const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); + const { client, isConnected } = usePullRequestConnection(serverId); const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/keyboard/shift/index.ts b/packages/app/src/keyboard/shift/index.ts index 3c56692919..5f057d4ecd 100644 --- a/packages/app/src/keyboard/shift/index.ts +++ b/packages/app/src/keyboard/shift/index.ts @@ -1,5 +1,4 @@ export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider"; export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context"; export { KeyboardTranslateView } from "./internal/translate"; -export { shouldUseCompactExplorerKeyboardPadding } from "./internal/policy"; export { useKeyboardStreamInset } from "./internal/stream"; diff --git a/packages/app/src/keyboard/shift/internal/policy.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts index 330cb2cfab..7c4902afe1 100644 --- a/packages/app/src/keyboard/shift/internal/policy.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -4,7 +4,6 @@ import { shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, reserveKeyboardLayoutShift, - shouldUseCompactExplorerKeyboardPadding, } from "./policy"; describe("keyboard layout reservation", () => { @@ -106,17 +105,3 @@ describe("shouldReconcileHiddenKeyboardEnd", () => { ).toBe(false); }); }); - -describe("shouldUseCompactExplorerKeyboardPadding", () => { - it("keeps the changes viewport stable while preserving padding for other tabs", () => { - expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe( - false, - ); - expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "files" })).toBe( - true, - ); - expect(shouldUseCompactExplorerKeyboardPadding({ isGit: false, explorerTab: "changes" })).toBe( - true, - ); - }); -}); diff --git a/packages/app/src/keyboard/shift/internal/policy.ts b/packages/app/src/keyboard/shift/internal/policy.ts index 97435027fa..fe496fd110 100644 --- a/packages/app/src/keyboard/shift/internal/policy.ts +++ b/packages/app/src/keyboard/shift/internal/policy.ts @@ -30,13 +30,6 @@ export function resolveStreamKeyboardInset(input: { }; } -export function shouldUseCompactExplorerKeyboardPadding(input: { - isGit: boolean; - explorerTab: "changes" | "files" | "pr"; -}): boolean { - return !input.isGit || input.explorerTab !== "changes"; -} - export function resolveKeyboardShift(input: { rawKeyboardHeight: number; keyboardProgress: number; diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/mobile-panels/explorer-open-gesture.tsx similarity index 96% rename from packages/app/src/components/compact-explorer-sidebar-host.tsx rename to packages/app/src/mobile-panels/explorer-open-gesture.tsx index 08f49fdb7d..a71f0489cf 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/mobile-panels/explorer-open-gesture.tsx @@ -32,7 +32,7 @@ function CompactExplorerOpenGestureSurface({ ); } -export function CompactExplorerSidebarHost({ +export function WorkspaceExplorerOpenGesture({ children, enabled, }: { @@ -46,7 +46,6 @@ export function CompactExplorerSidebarHost({ showExplorerSidebar({ isCompact: true, workspaceKey: `${selection.serverId}:${selection.workspaceId}`, - checkout: null, }); }, [selection, workspace]); return ( diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index 84e5d48d09..2b3f3e11fe 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -1,5 +1,5 @@ -import { useCallback, useMemo } from "react"; -import { Gesture } from "react-native-gesture-handler"; +import { createContext, useContext, useRef, type RefObject, useCallback, useMemo } from "react"; +import { Gesture, type GestureType } from "react-native-gesture-handler"; import { useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { isWeb } from "@/constants/platform"; @@ -324,16 +324,14 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer ); } -export function useCloseFileExplorerGesture() { - const { - beginGesture, - finishGesture, - motionState, - position, - rightCloseGestureRef, - updateGesture, - windowWidth, - } = useMobilePanelsRuntime(); +export const FileExplorerCloseGestureContext = createContext | null>(null); + +export function useCloseFileExplorerGesture(enabled = true) { + const rightCloseGestureRef = useRef(undefined); + const { beginGesture, finishGesture, motionState, position, updateGesture, windowWidth } = + useMobilePanelsRuntime(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const horizontalScroll = useHorizontalScrollOptional(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); @@ -343,6 +341,7 @@ export function useCloseFileExplorerGesture() { () => Gesture.Pan() .withRef(rightCloseGestureRef) + .enabled(enabled) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; @@ -401,6 +400,7 @@ export function useCloseFileExplorerGesture() { } }), [ + enabled, beginGesture, commit, finishGesture, @@ -420,5 +420,5 @@ export function useCloseFileExplorerGesture() { } export function useFileExplorerCloseGestureRef() { - return useMobilePanelsRuntime().rightCloseGestureRef; + return useContext(FileExplorerCloseGestureContext); } diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index d5087349a0..cb919e4595 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -1,5 +1,5 @@ -import { useMemo, type ComponentProps, type ReactNode } from "react"; -import { Pressable, StyleSheet, View } from "react-native"; +import { useId, useMemo, type ComponentProps, type ReactNode } from "react"; +import { Pressable, StyleSheet } from "react-native"; import { GestureDetector, type GestureType } from "react-native-gesture-handler"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { isWeb } from "@/constants/platform"; @@ -14,6 +14,9 @@ interface MobilePanelOverlayProps { children: ReactNode; closeGesture: GestureType; panel: OverlayPanel; + presentation?: "overlay" | "dock"; + dockVisible?: boolean; + frameStyle?: ComponentProps["style"]; panelStyle?: ComponentProps["style"]; } @@ -22,18 +25,25 @@ export function MobilePanelOverlay({ closeGesture, panel, panelStyle, + presentation = "overlay", + dockVisible = false, + frameStyle, }: MobilePanelOverlayProps) { const { position, windowWidth } = useMobilePanelsRuntime(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const isOpen = useIsMobilePanelActive(panel); + const overlayOpen = useIsMobilePanelActive(panel); + const docked = presentation === "dock"; + const isOpen = docked ? dockVisible : overlayOpen; + const hostId = useId(); const isLeft = panel === "agent-list"; const sidebarAnimatedStyle = useAnimatedStyle(() => { + if (docked) return { transform: [{ translateX: 0 }] }; const frame = getMobilePanelFrame(position.value, windowWidth); return { transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }], }; - }, [isLeft, windowWidth]); + }, [docked, isLeft, windowWidth]); const backdropAnimatedStyle = useAnimatedStyle(() => { const frame = getMobilePanelFrame(position.value, windowWidth); @@ -41,6 +51,7 @@ export function MobilePanelOverlay({ }, [isLeft, windowWidth]); const overlayAnimatedStyle = useAnimatedStyle(() => { + if (docked) return { opacity: 1, display: "flex" as const, pointerEvents: "box-none" as const }; const isVisible = isLeft ? position.value < 0 : position.value > 0; if (isWeb) { return { display: isVisible ? ("flex" as const) : ("none" as const) }; @@ -51,7 +62,7 @@ export function MobilePanelOverlay({ opacity: isVisible ? 1 : 0, pointerEvents: isVisible ? ("box-none" as const) : ("none" as const), }; - }, [isLeft]); + }, [docked, isLeft]); const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel; const backdropStyle = useMemo( @@ -60,13 +71,13 @@ export function MobilePanelOverlay({ ); const combinedPanelStyle = useMemo( () => [ - styles.panel, - positionedPanelStyle, - { width: windowWidth }, + docked ? styles.dockedPanel : styles.panel, + !docked && positionedPanelStyle, + !docked && { width: windowWidth }, panelStyle, sidebarAnimatedStyle, ], - [panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth], + [docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth], ); let overlayPointerEvents: "auto" | "box-none" | "none"; if (!isWeb) { @@ -79,22 +90,26 @@ export function MobilePanelOverlay({ {/* Fabric needs an always-mounted native host to attach the close handler while the retained panel content is hidden. nativeID keeps that host registered. */} - - + @@ -104,7 +119,7 @@ export function MobilePanelOverlay({ {children} - + ); } @@ -113,6 +128,8 @@ export function MobilePanelOverlay({ // Theme values stay inline at call sites, avoiding Unistyles patching the same // native node after Fabric commits. const styles = StyleSheet.create({ + dock: { flexShrink: 0, minHeight: 0 }, + dockedPanel: { flex: 1, minHeight: 0, overflow: "hidden" }, overlay: { ...StyleSheet.absoluteFill, zIndex: 1, diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx index f88e3e6e74..b2f97a2547 100644 --- a/packages/app/src/mobile-panels/provider.tsx +++ b/packages/app/src/mobile-panels/provider.tsx @@ -49,7 +49,6 @@ interface MobilePanelsRuntime { motionState: SharedValue; openGesturesBlocked: SharedValue; position: SharedValue; - rightCloseGestureRef: RefObject; rightOpenGestureRef: RefObject; updateGesture: (startedRevision: number, nextPosition: number) => boolean; setOpenGestureBlocked: (owner: symbol, blocked: boolean) => void; @@ -79,7 +78,6 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { const leftOpenGestureRef = useRef(undefined); const leftCloseGestureRef = useRef(undefined); const rightOpenGestureRef = useRef(undefined); - const rightCloseGestureRef = useRef(undefined); const [activePanel, setActivePanel] = useState(initialSelection.target); const setOpenGestureBlocked = useCallback( @@ -222,7 +220,6 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { motionState, openGesturesBlocked, position, - rightCloseGestureRef, rightOpenGestureRef, updateGesture, setOpenGestureBlocked, diff --git a/packages/app/src/navigation/themed-stack.tsx b/packages/app/src/navigation/themed-stack.tsx index 45be19abc4..221cfc0ee4 100644 --- a/packages/app/src/navigation/themed-stack.tsx +++ b/packages/app/src/navigation/themed-stack.tsx @@ -22,6 +22,8 @@ interface ThemedStackBaseProps { // Screens whose content is another navigator. That navigator owns its own screens' appearance // boundary; wrapping it here would remount it. nestedNavigatorScreens?: readonly string[]; + /** Screens with retained lifetime owners update appearance at their own leaves. */ + screenOwnedAppearance?: readonly string[]; } interface ScreenContent { @@ -34,6 +36,7 @@ function ThemedStackBase({ children, screenOptions, nestedNavigatorScreens, + screenOwnedAppearance, }: ThemedStackBaseProps) { const themedScreenOptions = useMemo( () => ({ @@ -46,10 +49,15 @@ function ThemedStackBase({ // and its screen containers keep their identity. See docs/unistyles.md. const screenLayout = useCallback( ({ route, children: content }: ScreenContent): ReactElement => { - if (nestedNavigatorScreens?.includes(route.name) && isValidElement(content)) return content; + if ( + (nestedNavigatorScreens?.includes(route.name) || + screenOwnedAppearance?.includes(route.name)) && + isValidElement(content) + ) + return content; return {content}; }, - [nestedNavigatorScreens], + [nestedNavigatorScreens, screenOwnedAppearance], ); return ( diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 7ae7d0c470..2fa2624fb4 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1270,7 +1270,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId={serverId} workspaceId={workspaceId} agentId={agentId} - cwd={cwd} subagentRows={subagentRows} tasks={tasks} archiveFinishedStatus={archiveFinishedSubagents.status} @@ -1597,11 +1596,10 @@ function ActiveAgentComposer({ openWorkspaceChanges({ isCompact: isCompactFormFactor, workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), - checkout: { serverId, cwd, isGit: true }, preferences: openInSidePane, }); }, - [cwd, isCompactFormFactor, openInSidePane, serverId, workspaceId], + [isCompactFormFactor, openInSidePane, serverId, workspaceId], ); const handleClientSlashCommand = useCallback( diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 86faad329c..e67a004b45 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -33,7 +33,6 @@ export const AgentTracks = memo(function AgentTracks({ serverId, workspaceId, agentId, - cwd, subagentRows, tasks, archiveFinishedStatus, @@ -43,7 +42,6 @@ export const AgentTracks = memo(function AgentTracks({ serverId: string; workspaceId: string; agentId: string; - cwd: string; subagentRows: SubagentRow[]; tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; @@ -99,10 +97,9 @@ export const AgentTracks = memo(function AgentTracks({ openComposerChanges({ isCompact, workspaceKey, - checkout: { serverId, cwd, isGit: true }, preferences: openInSidePane, }); - }, [cwd, isCompact, openInSidePane, serverId, workspaceKey]); + }, [isCompact, openInSidePane, workspaceKey]); if ( !hasWorkspaceDiffStat && diff --git a/packages/app/src/panels/changes/presentation.test.ts b/packages/app/src/panels/changes/presentation.test.ts new file mode 100644 index 0000000000..e0504376d8 --- /dev/null +++ b/packages/app/src/panels/changes/presentation.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { resolveChangesPresentation } from "./presentation"; + +it.each([ + [true, false, false, "combined"], + [true, false, true, "combined"], + [true, true, false, "combined"], + [false, true, false, "combined"], + [false, true, true, "combined"], + [false, false, false, "tree"], + [false, false, true, "combined"], +] as const)( + "Changes compact=%s native=%s inline=%s renders %s", + (isCompact, isNative, inlineDiff, expected) => { + expect(resolveChangesPresentation({ isTree: true, isCompact, isNative, inlineDiff })).toBe( + expected, + ); + expect(resolveChangesPresentation({ isTree: false, isCompact, isNative, inlineDiff })).toBe( + "diff", + ); + }, +); diff --git a/packages/app/src/panels/changes/presentation.ts b/packages/app/src/panels/changes/presentation.ts new file mode 100644 index 0000000000..f3da9a405d --- /dev/null +++ b/packages/app/src/panels/changes/presentation.ts @@ -0,0 +1,9 @@ +export function resolveChangesPresentation(input: { + isTree: boolean; + inlineDiff: boolean; + isCompact: boolean; + isNative: boolean; +}): "tree" | "diff" | "combined" { + if (!input.isTree) return "diff"; + return input.isCompact || input.isNative || input.inlineDiff ? "combined" : "tree"; +} diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index 1297b278bb..b6fb30454f 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -1,3 +1,4 @@ +import { resolveChangesPresentation } from "./changes/presentation"; import { useCallback, useMemo, type ReactNode } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -7,7 +8,7 @@ import invariant from "tiny-invariant"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useIsCompactFormFactor } from "@/constants/layout"; import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; -import { isWeb } from "@/constants/platform"; +import { isWeb, isNative } from "@/constants/platform"; import { DiffDocument } from "@/git/diff-document"; import { ChangesSurface, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane"; import { useCommitDiffFiles } from "@/git/use-diff-files"; @@ -76,14 +77,6 @@ function PanelState({ ); } -function resolveChangesPresentation( - isTree: boolean, - inlineDiff: boolean, -): "tree" | "diff" | "combined" { - if (!isTree) return "diff"; - return inlineDiff ? "combined" : "tree"; -} - function ChangesPanel() { const { t } = useTranslation(); const { serverId, workspaceId, tabId, target, openPreferredTarget, openTargetToSide } = @@ -98,6 +91,7 @@ function ChangesPanel() { "ChangesPanel requires working_diff or changes_tree target", ); const isTree = target.kind === "changes_tree"; + const isCompact = useIsCompactFormFactor(); const handleOpenFile = useCallback( (path: string) => openPreferredTarget({ kind: "file", path }, isTree ? "diffs" : "diffFiles"), @@ -122,7 +116,12 @@ function ChangesPanel() { return ; } - const presentation = resolveChangesPresentation(isTree, preferences.inlineDiff); + const presentation = resolveChangesPresentation({ + isTree, + inlineDiff: preferences.inlineDiff, + isCompact, + isNative, + }); const testID = isTree ? "changes-tree-panel" : "working-diff-panel"; const profileId = isTree ? `ChangesTreePanel:${tabId}` : `WorkingDiffPanel:${tabId}`; diff --git a/packages/app/src/panels/pull-request-panel.tsx b/packages/app/src/panels/pull-request-panel.tsx index 53a6af8bd0..3d2fb92a50 100644 --- a/packages/app/src/panels/pull-request-panel.tsx +++ b/packages/app/src/panels/pull-request-panel.tsx @@ -1,21 +1,16 @@ +import { useRetainedPullRequestData } from "./pull-request/data"; +import { + pullRequestPanelPresentation, + usePullRequestPanelDescriptor, +} from "./pull-request/descriptor"; import { Text, View } from "react-native"; -import { GitPullRequest } from "lucide-react-native"; -import { withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; -import { formatPrTabLabel } from "@/git/pull-request-panel"; import { usePaneContext } from "@/panels/pane-context"; -import { definePanel, type PanelPresentation } from "@/panels/panel-registry"; -import { PullRequestContent, usePullRequestData } from "@/panels/pull-request"; +import { definePanel } from "@/panels/panel-registry"; +import { PullRequestContent } from "@/panels/pull-request"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; -const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const pullRequestPanelPresentation = { - label: (t) => t("panels.pullRequest.label"), - subtitle: (t) => t("panels.pullRequest.subtitle"), - tooltip: (t) => t("panels.pullRequest.label"), - icon: ThemedGitPullRequest, -} satisfies PanelPresentation; const CENTERED_PADDED_STYLE = { flex: 1, alignItems: "center", @@ -23,33 +18,12 @@ const CENTERED_PADDED_STYLE = { padding: 16, } as const; -function usePullRequestPanelDescriptor( - _target: { kind: "pull_request" }, - context: { serverId: string; workspaceId: string }, -) { - const { t } = useTranslation(); - const cwd = useWorkspaceDirectory(context.serverId, context.workspaceId) ?? ""; - const prPane = usePullRequestData({ serverId: context.serverId, cwd, timelineEnabled: false }); - const label = - prPane.prNumber === null - ? pullRequestPanelPresentation.label(t) - : formatPrTabLabel(prPane.prNumber); - return { - label, - subtitle: pullRequestPanelPresentation.subtitle(t), - tooltip: label, - titleState: prPane.isLoading ? ("loading" as const) : ("ready" as const), - icon: pullRequestPanelPresentation.icon, - statusBucket: null, - }; -} - function PullRequestPanel() { const { t } = useTranslation(); const { serverId, workspaceId, target } = usePaneContext(); const cwd = useWorkspaceDirectory(serverId, workspaceId) ?? ""; invariant(target.kind === "pull_request", "PullRequestPanel requires pull_request target"); - const prPane = usePullRequestData({ serverId, cwd }); + const prPane = useRetainedPullRequestData({ serverId, cwd }, "content"); if (!cwd) { return ( diff --git a/packages/app/src/panels/pull-request/activity.integration.test.tsx b/packages/app/src/panels/pull-request/activity.integration.test.tsx new file mode 100644 index 0000000000..2f2b97aab2 --- /dev/null +++ b/packages/app/src/panels/pull-request/activity.integration.test.tsx @@ -0,0 +1,249 @@ +/** @vitest-environment jsdom */ +import { ToastApiProvider } from "@/contexts/toast-api-context"; +import { act, useCallback, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { waitFor } from "@testing-library/dom"; +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const noop = () => {}; +const toastApi = { show: noop, error: noop, copied: noop }; +const roots: ReturnType[] = []; +function render(children: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(children)); + return { + rerender: (next: ReactNode) => act(() => root.render(next)), + unmount: () => act(() => root.unmount()), + }; +} +function cleanup() { + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.replaceChildren(); +} + +import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { + PullRequestConnectionProvider, + type PullRequestConnection, +} from "@/git/pull-request-connection"; +import { usePullRequestPanelDescriptor } from "./descriptor"; +import { useRetainedPullRequestData } from "./data"; +import { useWorkspacePullRequestDiscovery } from "./discovery"; +import { useSessionStore } from "@/stores/session-store"; +import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +const serverId = "pr-demand", + workspaceId = "workspace", + workspaceKey = `${serverId}:${workspaceId}`, + cwd = "/repo"; +function Content() { + const data = useRetainedPullRequestData({ serverId, cwd }, "content"); + return {data.data?.title}; +} +function Rail() { + const descriptor = usePullRequestPanelDescriptor( + { kind: "pull_request" }, + { serverId, workspaceId }, + ); + return {descriptor.label}; +} +function Discovery({ active, workspaceActive }: { active: boolean; workspaceActive: boolean }) { + useWorkspacePullRequestDiscovery({ + workspaceKey, + serverId, + cwd, + workspaceActive, + isGit: true, + layoutMode: "single-pane", + explorerOpen: active, + }); + return null; +} +function createSource() { + const calls = { status: 0, timeline: 0 }; + const client: NonNullable = { + async checkoutPrStatus() { + calls.status++; + return { + cwd, + forge: "github", + requestId: "status", + error: null, + githubFeaturesEnabled: true, + status: { + forge: "github", + number: 42, + url: "https://github.com/acme/repo/pull/42", + title: "Retained PR", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: false, + mergeable: "UNKNOWN", + checks: [], + reviewDecision: null, + repoOwner: "acme", + repoName: "repo", + }, + }; + }, + async pullRequestTimeline() { + calls.timeline++; + return { + cwd, + requestId: "timeline", + prNumber: 42, + items: [], + truncated: false, + error: null, + githubFeaturesEnabled: true, + }; + }, + }; + return { client, calls }; +} +beforeEach(async () => { + useSessionStore.getState().initializeSession(serverId, null); + useSessionStore.getState().setWorkspaces( + serverId, + new Map([ + [ + workspaceId, + { + id: workspaceId, + projectId: "project", + projectDisplayName: "Project", + projectCustomName: null, + projectRootPath: cwd, + workspaceDirectory: cwd, + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }, + ], + ]), + ); + await useWorkspaceLayoutStore.persist.rehydrate(); + useWorkspaceLayoutStore.setState({ + layoutMode: "single-pane", + layoutByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + }); +}); +afterEach(() => { + cleanup(); + useSessionStore.getState().clearSession(serverId); + onlineManager.setOnline(true); +}); +it("runs real PR descriptor/content/discovery hooks through hidden invalidation, reconnect and close opt-out", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const source = createSource(); + useWorkspaceLayoutStore + .getState() + .openTab({ workspaceKey, target: { kind: "files" }, intent: "reveal" }); + function Tree({ + surface, + content, + otherRail = false, + connected = true, + workspaceActive = true, + }: { + surface: boolean; + content: boolean; + otherRail?: boolean; + connected?: boolean; + workspaceActive?: boolean; + }) { + const connection = useCallback( + () => ({ client: source.client, isConnected: connected }), + [connected], + ); + return ( + + + + + + + + + + + + {otherRail ? : null} + + + ); + } + const tabs = () => + collectAllTabs(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root); + const view = render(); + expect(source.calls).toEqual({ status: 0, timeline: 0 }); + view.rerender(); + await waitFor(() => expect(source.calls).toEqual({ status: 1, timeline: 0 })); + await waitFor(() => expect(tabs().some((tab) => tab.target.kind === "pull_request")).toBe(true)); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe( + "main", + ); + view.rerender(); + await waitFor(() => expect(source.calls).toEqual({ status: 1, timeline: 1 })); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + await waitFor(() => + expect(document.querySelector('[data-testid="content"]')?.textContent).toBe("Retained PR"), + ); + const cached = queryClient + .getQueryCache() + .getAll() + .map((query) => query.state.data); + view.rerender(); + await act(async () => { + onlineManager.setOnline(false); + await queryClient.invalidateQueries(); + onlineManager.setOnline(true); + }); + view.rerender(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + expect(source.calls).toEqual({ status: 1, timeline: 1 }); + expect( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.state.data), + ).toEqual(cached); + view.rerender(); + await waitFor(() => expect(source.calls).toEqual({ status: 2, timeline: 1 })); + view.rerender(); + await waitFor(() => expect(source.calls).toEqual({ status: 2, timeline: 2 })); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + const prTab = tabs().find((tab) => tab.target.kind === "pull_request")!; + act(() => useWorkspaceLayoutStore.getState().closeTab(workspaceKey, prTab.tabId)); + view.rerender(); + await act(async () => { + await queryClient.invalidateQueries(); + }); + view.rerender(); + await waitFor(() => expect(source.calls).toEqual({ status: 3, timeline: 2 })); + expect(tabs().some((tab) => tab.target.kind === "pull_request")).toBe(false); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + view.rerender(); + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(source.calls).toEqual({ status: 3, timeline: 2 }); + view.rerender(); + await waitFor(() => expect(source.calls).toEqual({ status: 4, timeline: 3 })); + expect(tabs().some((tab) => tab.target.kind === "pull_request")).toBe(false); + view.unmount(); + queryClient.clear(); +}); diff --git a/packages/app/src/panels/pull-request/activity.test.ts b/packages/app/src/panels/pull-request/activity.test.ts new file mode 100644 index 0000000000..8177f49639 --- /dev/null +++ b/packages/app/src/panels/pull-request/activity.test.ts @@ -0,0 +1,51 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { expect, it } from "vitest"; +import { pullRequestDemand, shouldDiscoverPullRequest } from "./activity"; + +it("stops retained PR summary and timeline demand, then catches up once on reopen", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const calls = { status: 0, timeline: 0 }; + const options = (kind: "status" | "timeline", active: boolean, consumer: "rail" | "content") => ({ + queryKey: [kind], + queryFn: async () => ++calls[kind], + enabled: + kind === "status" + ? pullRequestDemand(active, consumer).enabled + : pullRequestDemand(active, consumer).timelineEnabled, + }); + const status = new QueryObserver(client, options("status", true, "content")); + const timeline = new QueryObserver(client, options("timeline", true, "content")); + const unsubscribe = [status.subscribe(() => {}), timeline.subscribe(() => {})]; + await Promise.all([status.refetch(), timeline.refetch()]); + expect(calls).toEqual({ status: 1, timeline: 1 }); + status.setOptions(options("status", false, "content")); + timeline.setOptions(options("timeline", false, "content")); + await client.invalidateQueries(); + expect(calls).toEqual({ status: 1, timeline: 1 }); + status.setOptions(options("status", true, "rail")); + timeline.setOptions(options("timeline", true, "rail")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toEqual({ status: 2, timeline: 1 }); + timeline.setOptions(options("timeline", true, "content")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toEqual({ status: 2, timeline: 2 }); + unsubscribe.forEach((stop) => stop()); + client.clear(); +}); + +it.each([ + [true, true, true, false, true], + [true, true, false, true, true], + [true, true, false, false, false], + [false, true, true, true, false], + [true, false, true, true, false], +])( + "budgets discovery independently of tab existence", + (workspaceActive, isGit, splitLayout, explorerActive, expected) => { + expect(shouldDiscoverPullRequest({ workspaceActive, isGit, splitLayout, explorerActive })).toBe( + expected, + ); + }, +); diff --git a/packages/app/src/panels/pull-request/activity.ts b/packages/app/src/panels/pull-request/activity.ts new file mode 100644 index 0000000000..4b83308521 --- /dev/null +++ b/packages/app/src/panels/pull-request/activity.ts @@ -0,0 +1,12 @@ +/** A rail needs summary status; the selected content additionally needs timeline data. */ +export function pullRequestDemand(active: boolean, consumer: "rail" | "content") { + return { enabled: active, timelineEnabled: active && consumer === "content" }; +} +export function shouldDiscoverPullRequest(input: { + workspaceActive: boolean; + isGit: boolean; + splitLayout: boolean; + explorerActive: boolean; +}): boolean { + return input.workspaceActive && input.isGit && (input.splitLayout || input.explorerActive); +} diff --git a/packages/app/src/panels/pull-request/data.ts b/packages/app/src/panels/pull-request/data.ts new file mode 100644 index 0000000000..5ec0bcfc31 --- /dev/null +++ b/packages/app/src/panels/pull-request/data.ts @@ -0,0 +1,11 @@ +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { usePrPaneData } from "@/git/pull-request-panel/use-data"; +import { pullRequestDemand } from "./activity"; +export { usePrPaneData as usePullRequestData } from "@/git/pull-request-panel/use-data"; +export function useRetainedPullRequestData( + input: { serverId: string; cwd: string }, + role: "rail" | "content", +) { + const active = useRetainedPanelActive(); + return usePrPaneData({ ...input, ...pullRequestDemand(active, role) }); +} diff --git a/packages/app/src/panels/pull-request/descriptor.tsx b/packages/app/src/panels/pull-request/descriptor.tsx new file mode 100644 index 0000000000..6eb9f20ed2 --- /dev/null +++ b/packages/app/src/panels/pull-request/descriptor.tsx @@ -0,0 +1,40 @@ +import { GitPullRequest } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { formatPrTabLabel } from "@/git/pull-request-panel/tab-label"; +import type { PanelPresentation } from "@/panels/panel-registry"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useRetainedPullRequestData } from "./data"; +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +export const pullRequestPanelPresentation = { + label: (t) => t("panels.pullRequest.label"), + subtitle: (t) => t("panels.pullRequest.subtitle"), + tooltip: (t) => t("panels.pullRequest.label"), + icon: ThemedGitPullRequest, +} satisfies PanelPresentation; +export function usePullRequestPanelDescriptor( + _target: { kind: "pull_request" }, + context: { serverId: string; workspaceId: string }, +) { + const { t } = useTranslation(); + const cwd = useWorkspaceDirectory(context.serverId, context.workspaceId) ?? ""; + const prPane = useRetainedPullRequestData( + { + serverId: context.serverId, + cwd, + }, + "rail", + ); + const label = + prPane.prNumber === null + ? pullRequestPanelPresentation.label(t) + : formatPrTabLabel(prPane.prNumber); + return { + label, + subtitle: pullRequestPanelPresentation.subtitle(t), + tooltip: label, + titleState: prPane.isLoading ? ("loading" as const) : ("ready" as const), + icon: pullRequestPanelPresentation.icon, + statusBucket: null, + }; +} diff --git a/packages/app/src/panels/pull-request/discovery.ts b/packages/app/src/panels/pull-request/discovery.ts new file mode 100644 index 0000000000..43df9c870f --- /dev/null +++ b/packages/app/src/panels/pull-request/discovery.ts @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; +import { shouldDiscoverPullRequest } from "./activity"; +import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { useSettings } from "@/hooks/use-settings"; +import { + useWorkspaceLayoutStore, + useWorkspaceLayoutStoreHydrated, +} from "@/stores/workspace-layout-store"; +import { autoOpenWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; +import { getPullRequestIdentity } from "./state"; +/** Workspace discovery remains independent from whether an ordinary PR tab exists. */ +export function useWorkspacePullRequestDiscovery(input: { + workspaceKey: string | null; + serverId: string; + cwd: string | null; + workspaceActive: boolean; + isGit: boolean; + layoutMode: "compact" | "single-pane" | "split-panes"; + explorerOpen: boolean; +}): boolean { + const settledExplorerActive = useIsMobilePanelActive("file-explorer"); + const enabled = shouldDiscoverPullRequest({ + workspaceActive: input.workspaceActive, + isGit: input.isGit, + splitLayout: input.layoutMode === "split-panes", + explorerActive: input.layoutMode === "compact" ? settledExplorerActive : input.explorerOpen, + }); + const hasPullRequest = useHasPullRequest({ serverId: input.serverId, cwd: input.cwd, enabled }); + usePullRequestAutoAdd({ workspaceKey: input.workspaceKey, hasPullRequest, enabled }); + return hasPullRequest; +} + +/** + * Whether this workspace's branch has a pull request. + */ +export function useHasPullRequest(input: { + serverId: string; + cwd: string | null; + enabled: boolean; +}): boolean { + const status = useCheckoutPrStatusQuery({ + serverId: input.serverId, + cwd: input.cwd || "", + enabled: input.enabled, + }).status; + return getPullRequestIdentity(status) !== null; +} + +/** Connects desktop PR detection to the persisted layout after hydration. */ +export function usePullRequestAutoAdd(input: { + workspaceKey: string | null; + hasPullRequest: boolean; + enabled: boolean; +}): void { + const hydrated = useWorkspaceLayoutStoreHydrated(); + const preferredDestination = useSettings((settings) => settings.pullRequestOpenLocation); + const splitLayout = useWorkspaceLayoutStore((state) => state.layoutMode === "split-panes"); + const destination = splitLayout ? preferredDestination : "explorer"; + useEffect(() => { + if (hydrated && input.enabled && input.hasPullRequest) { + autoOpenWorkspacePullRequest({ workspaceKey: input.workspaceKey, destination }); + } + }, [hydrated, input.enabled, input.hasPullRequest, input.workspaceKey, destination]); +} diff --git a/packages/app/src/panels/pull-request/index.tsx b/packages/app/src/panels/pull-request/index.tsx index 148da86914..56ce378a4e 100644 --- a/packages/app/src/panels/pull-request/index.tsx +++ b/packages/app/src/panels/pull-request/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; @@ -9,47 +9,16 @@ import { PullRequestPane, PullRequestPaneError, PullRequestPaneSkeleton, - usePrPaneData, } from "@/git/pull-request-panel"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; -import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; -import { useSettings } from "@/hooks/use-settings"; -import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; -import { autoOpenWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; - -import { getPullRequestIdentity, resolvePullRequestContentState } from "./state"; - -/** - * Whether this workspace's branch has a pull request. - */ -export function useHasPullRequest(input: { - serverId: string; - cwd: string | null; - enabled: boolean; -}): boolean { - const status = useCheckoutPrStatusQuery({ - serverId: input.serverId, - cwd: input.cwd || "", - enabled: input.enabled, - }).status; - return getPullRequestIdentity(status) !== null; -} - -/** Connects desktop PR detection to the persisted layout after hydration. */ -export function usePullRequestAutoAdd(input: { - workspaceKey: string | null; - hasPullRequest: boolean; - enabled: boolean; -}): void { - const hydrated = useWorkspaceLayoutStoreHydrated(); - const destination = useSettings((settings) => settings.pullRequestOpenLocation); - useEffect(() => { - if (hydrated && input.enabled && input.hasPullRequest) { - autoOpenWorkspacePullRequest({ workspaceKey: input.workspaceKey, destination }); - } - }, [hydrated, input.enabled, input.hasPullRequest, input.workspaceKey, destination]); -} +import { resolvePullRequestContentState } from "./state"; +export { + useWorkspacePullRequestDiscovery, + useHasPullRequest, + usePullRequestAutoAdd, +} from "./discovery"; +export { usePullRequestData } from "./data"; export function PullRequestContent(input: { serverId: string; @@ -96,15 +65,6 @@ export function PullRequestContent(input: { ); } -export function usePullRequestData(input: { - serverId: string; - cwd: string; - enabled?: boolean; - timelineEnabled?: boolean; -}) { - return usePrPaneData(input); -} - const styles = StyleSheet.create((theme) => ({ empty: { flex: 1, diff --git a/packages/app/src/panels/retained-chat-content.native.tsx b/packages/app/src/panels/retained-chat-content.native.tsx new file mode 100644 index 0000000000..0ed1d2dfc8 --- /dev/null +++ b/packages/app/src/panels/retained-chat-content.native.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +/** + * Native: a hidden chat stays mounted and keeps rendering. Suspending a retained + * native subtree hides its host views, tears down layout effects, and detaches + * refs, which changes Fabric ownership (docs/mobile-panels.md). Activity-gated + * work already stops through the retained-panel signal. + */ +export function RetainedChatContent({ children }: { children: ReactNode }) { + return children; +} diff --git a/packages/app/src/panels/retained-chat-content.test.tsx b/packages/app/src/panels/retained-chat-content.test.tsx new file mode 100644 index 0000000000..a4efcc3526 --- /dev/null +++ b/packages/app/src/panels/retained-chat-content.test.tsx @@ -0,0 +1,102 @@ +/** @vitest-environment jsdom */ +import { act, useLayoutEffect, useState, useSyncExternalStore } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { RetainedChatContent as NativeChat } from "./retained-chat-content.native"; +import { RetainedChatContent as WebChat } from "./retained-chat-content.web"; +import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +for (const [runtime, Chat] of [ + ["native", NativeChat], + ["web", WebChat], +] as const) { + it(`${runtime} retains local state while applying its hidden rendering policy`, async () => { + let version = 0, + commits = 0, + mounts = 0; + const listeners = new Set<() => void>(); + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + function Content() { + const value = useSyncExternalStore(subscribe, () => version); + const [local] = useState(() => "unsaved-local-draft"); + useLayoutEffect(() => { + mounts++; + }, []); + useLayoutEffect(() => { + commits++; + }); + return ( + + {local}:{value} + + ); + } + const render = (active: boolean) => + act(() => + root.render( + + + + + , + ), + ); + render(true); + render(false); + const hiddenCommits = commits; + await act(async () => { + version++; + listeners.forEach((listener) => listener()); + }); + expect(commits - hiddenCommits).toBe(runtime === "native" ? 1 : 0); + render(true); + expect(container.textContent).toBe("unsaved-local-draft:1"); + // Web Suspense reruns layout effects on reveal; local state survives in both runtimes. + expect(mounts).toBe(runtime === "native" ? 1 : 2); + }); +} + +it("retains modified panels beyond the LRU cap and evicts them normally after save", () => { + let mounted: string[] = []; + const allTabIds = ["a", "b", "c", "d", "e"]; + function Host({ active, modified }: { active: string; modified: Set }) { + const { mountedTabIds } = useMountedTabSet({ + activeTabId: active, + allTabIds, + retainedTabIds: modified, + cap: 3, + }); + mounted = [...mountedTabIds]; + return null; + } + const render = (active: string, modified = new Set()) => + act(() => root.render()); + render("a"); + render("b"); + render("c"); + render("d"); + expect(mounted).toEqual(["d", "c", "b"]); + render("e", new Set(["a", "b", "c", "d"])); + expect(mounted).toEqual(["e", "a", "b", "c", "d"]); + render("e"); + expect(mounted).toEqual(["e", "a", "b"]); +}); diff --git a/packages/app/src/panels/retained-chat-content.tsx b/packages/app/src/panels/retained-chat-content.tsx index 0ed1d2dfc8..31117e4655 100644 --- a/packages/app/src/panels/retained-chat-content.tsx +++ b/packages/app/src/panels/retained-chat-content.tsx @@ -1,11 +1 @@ -import type { ReactNode } from "react"; - -/** - * Native: a hidden chat stays mounted and keeps rendering. Suspending a retained - * native subtree hides its host views, tears down layout effects, and detaches - * refs, which changes Fabric ownership (docs/mobile-panels.md). Activity-gated - * work already stops through the retained-panel signal. - */ -export function RetainedChatContent({ children }: { children: ReactNode }) { - return children; -} +export { RetainedChatContent } from "./retained-chat-content.native"; diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 8082bd36e5..3492c40cac 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -11,7 +11,9 @@ import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { queryClient } from "@/data/query-client"; import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state"; -import { usePanelStore } from "@/stores/panel-store"; +import { showExplorerSidebar } from "@/workspace-tabs/explorer-sidebar"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; @@ -80,17 +82,18 @@ function TerminalPanel() { const { isWorkspaceFocused, isPaneFocused } = usePaneFocus(); const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ workspaceDirectory: w.workspaceDirectory, - isGitCheckout: w.projectKind === "git", })); const workspaceDirectory = workspaceFields?.workspaceDirectory || null; - const isGitCheckout = workspaceFields?.isGitCheckout ?? false; - const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer); + const isCompact = useIsCompactFormFactor(); const handleOpenFileExplorer = useCallback(() => { if (!workspaceDirectory) { return; } - openCompactFileExplorer({ serverId, cwd: workspaceDirectory, isGit: isGitCheckout }); - }, [isGitCheckout, openCompactFileExplorer, serverId, workspaceDirectory]); + showExplorerSidebar({ + isCompact, + workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + }); + }, [isCompact, serverId, workspaceId, workspaceDirectory]); invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); if (!workspaceDirectory) { diff --git a/packages/app/src/panels/use-add-file-to-chat.ts b/packages/app/src/panels/use-add-file-to-chat.ts index 8261a2b0a9..f5cbd99287 100644 --- a/packages/app/src/panels/use-add-file-to-chat.ts +++ b/packages/app/src/panels/use-add-file-to-chat.ts @@ -1,3 +1,4 @@ +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import { useCallback, useMemo } from "react"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; @@ -12,7 +13,6 @@ export function useAddFileToChat(input: { serverId: string; workspaceId?: string const layout = useWorkspaceLayoutStore((state) => workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, ); - const focusTab = useWorkspaceLayoutStore((state) => state.focusTab); const focusedChat = useMemo( () => resolveFocusedChatTarget({ serverId: input.serverId, layout }), [input.serverId, layout], @@ -26,9 +26,9 @@ export function useAddFileToChat(input: { serverId: string; workspaceId?: string draftKey: focusedChat.draftKey, attachment: createWorkspaceFileAttachment({ path: filePath }), }); - focusTab(workspaceKey, focusedChat.tabId); + revealWorkspaceTab(workspaceKey, focusedChat.tabId); }, - [focusTab, focusedChat, workspaceKey], + [focusedChat, workspaceKey], ); return { addFile, canAddToChat: focusedChat !== null }; } diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index 754c81cd04..e39106b65b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -1,10 +1,9 @@ import { useCallback, useMemo, type ReactNode } from "react"; import { View } from "react-native"; -import { isWeb } from "@/constants/platform"; +import { useWorkspaceTabDrag } from "@/components/workspace-tab-drag-context"; import { StyleSheet } from "react-native-unistyles"; import { RetainedPanel } from "@/components/retained-panel"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import type { TabDropPreview } from "@/components/split-container-tab-drop-preview"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import { @@ -40,8 +39,6 @@ interface ExplorerSidebarDockProps extends Pick< normalizedWorkspaceId: string; isWorkspaceFocused: boolean; closingTabIds: Set; - activeDragTabId: string | null; - tabDropPreview: TabDropPreview | null; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; @@ -60,8 +57,6 @@ export function ExplorerSidebarDock({ normalizedWorkspaceId, isWorkspaceFocused, closingTabIds, - activeDragTabId, - tabDropPreview, onSelectTab, onCloseTab, onCreateNewTab, @@ -81,6 +76,7 @@ export function ExplorerSidebarDock({ buildPaneContentModel, headerAction, }: ExplorerSidebarDockProps) { + const { activeDragTabId, tabDropPreview, externalDndContext } = useWorkspaceTabDrag(); const paneState = useMemo(() => deriveWorkspacePaneState({ pane, tabs: uiTabs }), [pane, uiTabs]); const tabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]); const activeTabId = paneState.activeTabId; @@ -145,7 +141,7 @@ export function ExplorerSidebarDock({ onCloseOtherTabs={handleCloseOtherTabs} focusModeEnabled={false} onExitFocusMode={onExitFocusMode} - externalDndContext={isWeb} + externalDndContext={externalDndContext} paneId={pane.id} tabs={tabItems} normalizedServerId={normalizedServerId} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts b/packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts new file mode 100644 index 0000000000..d99686cd7f --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +export interface ExplorerFrameProps { + children: ReactNode; + presentation: "overlay" | "dock"; + persistenceKey: string; + containerWidth: number; + isOpen: boolean; + isWorkspaceActive: boolean; + surfaceColor?: string; +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/frame.tsx b/packages/app/src/screens/workspace/explorer-sidebar/frame.tsx new file mode 100644 index 0000000000..f166233a2e --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/frame.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + FileExplorerCloseGestureContext, + useCloseFileExplorerGesture, +} from "@/mobile-panels/gestures"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; +import { MobilePanelOverlay } from "@/mobile-panels/presentation"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { useExplorerGeometry } from "./geometry"; +import type { ExplorerFrameProps } from "./frame-props"; + +function ExplorerFrameBase({ + children, + surfaceColor, + presentation, + isOpen, + isWorkspaceActive, + persistenceKey, + containerWidth, +}: ExplorerFrameProps) { + const overlay = presentation === "overlay"; + const settledOpen = useIsMobilePanelActive("file-explorer"); + const active = isWorkspaceActive && (overlay ? settledOpen : isOpen); + const resizeEnabled = isWorkspaceActive && !overlay && isOpen; + const { gesture, gestureRef } = useCloseFileExplorerGesture(isWorkspaceActive && overlay); + const storedWidth = useWorkspaceLayoutStore( + (state) => state.explorerSidebarWidthByWorkspace[persistenceKey], + ); + const resize = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); + const onResize = useCallback( + (width: number) => resize(persistenceKey, width), + [persistenceKey, resize], + ); + const geometry = useExplorerGeometry({ + width: resolveExplorerSidebarWidth({ requestedWidth: storedWidth, containerWidth }), + containerWidth, + overlay, + resizeEnabled, + onResize, + }); + return ( + + + + + {resizeEnabled ? geometry.resizeHandle : null} + {children} + + + + + ); +} +export const ExplorerFrame = withUnistyles(ExplorerFrameBase, (theme) => ({ + surfaceColor: theme.colors.surfaceSidebar, +})); +const styles = StyleSheet.create({ content: { flex: 1, minHeight: 0 } }); diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts b/packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts new file mode 100644 index 0000000000..975dc721bd --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts @@ -0,0 +1,15 @@ +import type { ComponentProps, ReactNode } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; +import type { MobilePanelOverlay } from "@/mobile-panels/presentation"; +export interface ExplorerGeometryInput { + width: number; + containerWidth: number; + overlay: boolean; + resizeEnabled: boolean; + onResize: (width: number) => void; +} +export interface ExplorerGeometry { + frameStyle: ComponentProps["frameStyle"]; + panelStyle: StyleProp; + resizeHandle: ReactNode; +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx b/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx new file mode 100644 index 0000000000..4714467985 --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Gesture } from "react-native-gesture-handler"; +import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; +import { + SIDEBAR_RESIZE_ACTIVATION_OFFSET, + SIDEBAR_RESIZE_FAIL_OFFSET, +} from "@/components/sidebar-resize-handle-layout"; +import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout"; +import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types"; +export function useExplorerGeometry({ + width, + containerWidth, + overlay, + resizeEnabled, + onResize, +}: ExplorerGeometryInput): ExplorerGeometry { + const insets = useSafeAreaInsets(); + const resizeWidth = useSharedValue(width); + const startWidth = useSharedValue(width); + const [resizePressed, setResizePressed] = useState(false); + useEffect(() => { + resizeWidth.value = width; + }, [resizeWidth, width]); + + const showResizeGrip = useCallback(() => setResizePressed(true), []); + const hideResizeGrip = useCallback(() => setResizePressed(false), []); + const resizeGesture = useMemo( + () => + Gesture.Pan() + .enabled(resizeEnabled) + .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) + .onBegin(() => scheduleOnRN(showResizeGrip)) + .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) + .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) + .onStart((event) => { + startWidth.value = width + event.translationX; + resizeWidth.value = width; + }) + .onUpdate((event) => { + resizeWidth.value = resolveExplorerSidebarWidth({ + requestedWidth: startWidth.value - event.translationX, + containerWidth, + }); + }) + .onEnd(() => runOnJS(onResize)(resizeWidth.value)) + .onFinalize(() => scheduleOnRN(hideResizeGrip)), + [ + resizeEnabled, + onResize, + containerWidth, + hideResizeGrip, + resizeWidth, + showResizeGrip, + startWidth, + width, + ], + ); + // Reanimated retains the last animated value when its style is removed. Keep this + // style attached across folding and explicitly size overlays to the workspace viewport. + const animatedWidthStyle = useAnimatedStyle( + () => ({ + width: overlay ? containerWidth : resizeWidth.value, + }), + [containerWidth, overlay], + ); + return { + frameStyle: animatedWidthStyle, + panelStyle: { + paddingTop: insets.top + (overlay ? HEADER_TOP_PADDING_MOBILE : 0), + paddingLeft: overlay ? insets.left : 0, + paddingRight: overlay ? insets.right : 0, + }, + resizeHandle: ( + + ), + }; +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx b/packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx new file mode 100644 index 0000000000..09b6599197 --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx @@ -0,0 +1,57 @@ +import { useCallback, useState } from "react"; +import { View, StyleSheet } from "react-native"; +import { ResizeHandle } from "@/components/resize-handle"; +import { + resolveExplorerSidebarDockSizes, + resolveExplorerSidebarWidth, +} from "@/components/explorer-sidebar-layout"; +import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types"; +export function useExplorerGeometry({ + width, + containerWidth, + onResize, + overlay, +}: ExplorerGeometryInput): ExplorerGeometry { + const [previewWidth, setPreviewWidth] = useState(null); + const requestedWidth = previewWidth ?? width; + const sizes = resolveExplorerSidebarDockSizes({ requestedWidth, containerWidth }); + const preview = useCallback( + (_id: string, next: number[]) => { + setPreviewWidth( + resolveExplorerSidebarWidth({ requestedWidth: next[1] * containerWidth, containerWidth }), + ); + }, + [containerWidth], + ); + const commit = useCallback( + (_id: string, next: number[]) => { + setPreviewWidth(null); + onResize( + resolveExplorerSidebarWidth({ requestedWidth: next[1] * containerWidth, containerWidth }), + ); + }, + [containerWidth, onResize], + ); + return { + frameStyle: { width: overlay ? undefined : requestedWidth }, + panelStyle: undefined, + resizeHandle: ( + + + + ), + }; +} +const styles = StyleSheet.create({ + resize: { position: "absolute", left: 0, top: 0, bottom: 0, zIndex: 2 }, +}); 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 f1806ede34..4daeb96cb1 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -858,7 +858,6 @@ function TabChip({ testID={`workspace-tab-${testIdentity}`} triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined} style={tabChipStyle} - onPressIn={isWeb ? handleNavigateTab : undefined} onPress={handleNavigateTab} accessibilityRole="button" accessibilityLabel={accessibilityLabel} diff --git a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx index 5c6a1bb821..55a36f9779 100644 --- a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx +++ b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx @@ -89,8 +89,9 @@ export function WorkspaceHeaderExplorerToggle({ } export function WorkspaceExplorerSidebarToggle({ - owner: _owner, + owner, ...toggleProps }: DesktopWorkspaceExplorerToggleProps) { + if (owner === "header") return null; return ; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9925781931..530c1a07d0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -5,12 +5,11 @@ import { type InlinePathTarget, } from "@/assistant-file-links"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import type { LayoutChangeEvent } from "react-native"; import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; -import { - CompactExplorerSidebar, - NativeExplorerSidebarDock, -} from "@/components/compact-explorer-sidebar"; +import { ExplorerFrame } from "./explorer-sidebar/frame"; +import { WorkspaceTabDragScope } from "@/components/workspace-tab-drag-scope"; import { WorkspaceComposerEditingSessionsProvider, useComposerTabFullscreen, @@ -79,7 +78,6 @@ import { openWorkspaceTargetBeside, } from "@/workspace-tabs/open-beside"; 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"; import { @@ -221,7 +219,7 @@ import { } from "@/workspace/file-open"; import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; -import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; +import { useWorkspacePullRequestDiscovery } from "@/panels/pull-request"; const NO_SHEETS: readonly SheetTab[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; @@ -778,12 +776,14 @@ function WorkspaceContentProviders({ ); } -function WorkspacePanelProviders({ +function WorkspacePanelSurface({ launcher, children, tabIds, workspaceKey, + onLayout, }: { + onLayout: (event: LayoutChangeEvent) => void; workspaceKey: string; launcher: NewTabLauncher; children: ReactNode; @@ -791,7 +791,13 @@ function WorkspacePanelProviders({ }) { return ( - {children} + + + + {children} + + + ); } @@ -880,14 +886,6 @@ function paneLocalPlacement(paneId: string | null | undefined): WorkspaceTabPlac return paneId ? { mode: "pane", paneId } : FOCUSED_PANE_PLACEMENT; } -function canDetectPullRequest( - isRouteFocused: boolean, - isGitCheckout: boolean, - isCompact: boolean, -): boolean { - return isRouteFocused && isGitCheckout && !isCompact && supportsDesktopPaneSplits(); -} - interface WorkspaceTerminalTabActionsInput { persistenceKey: string | null; openWorkspaceTabFocused: ( @@ -1023,6 +1021,8 @@ function WorkspaceScreenContent({ const _insets = useSafeAreaInsets(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); + const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); + const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode); const hasMacTrafficLights = useHasWindowChromeObstruction("top-left"); const explorerToggleOwner = resolveWorkspaceExplorerToggleOwner({ isMobile, @@ -1255,35 +1255,22 @@ function WorkspaceScreenContent({ workspace: workspaceDescriptor, checkoutState: workspaceHeaderCheckoutState, }); - const canDetectPullRequestTab = canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile); - const hasPullRequest = useHasPullRequest({ - serverId: normalizedServerId, - cwd: workspaceDirectory, - enabled: canDetectPullRequestTab, - }); - usePullRequestAutoAdd({ - workspaceKey: persistenceKey, - hasPullRequest, - enabled: canDetectPullRequestTab, - }); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const activeExplorerCheckout = useMemo(() => { - if (!normalizedServerId || !workspaceDirectory) { - return null; - } - return { - serverId: normalizedServerId, - cwd: workspaceDirectory, - isGit: isGitCheckout, - }; - }, [isGitCheckout, normalizedServerId, workspaceDirectory]); - const isExplorerSidebarShowing = useIsExplorerSidebarOpen({ isCompact: isMobile, workspaceKey: persistenceKey, }); + const hasPullRequest = useWorkspacePullRequestDiscovery({ + workspaceKey: persistenceKey, + serverId: normalizedServerId, + cwd: workspaceDirectory, + workspaceActive: isRouteFocused, + isGit: isGitCheckout, + layoutMode, + explorerOpen: isExplorerSidebarShowing, + }); + const explorerSidebarToggleAccessibilityState = useMemo( () => ({ expanded: isExplorerSidebarShowing }), [isExplorerSidebarShowing], @@ -1353,7 +1340,6 @@ function WorkspaceScreenContent({ ); 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); const sheets = useWorkspaceLayoutStore((state) => @@ -1375,9 +1361,8 @@ function WorkspaceScreenContent({ toggleExplorerSidebar({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, }); - }, [activeExplorerCheckout, isMobile, persistenceKey]); + }, [isMobile, persistenceKey]); const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); @@ -1599,9 +1584,9 @@ function WorkspaceScreenContent({ if (!tabId || !persistenceKey) { return; } - focusWorkspaceTab(persistenceKey, tabId); + revealWorkspaceTab(persistenceKey, tabId); }, - [focusWorkspaceTab, persistenceKey], + [persistenceKey], ); const selectTabInPane = useCallback( (paneId: string, tabId: string) => { @@ -1715,9 +1700,6 @@ function WorkspaceScreenContent({ if (!location) { return; } - if (isMobile) { - showMobileAgent(); - } if (!persistenceKey) { return; } @@ -1731,17 +1713,9 @@ function WorkspaceScreenContent({ }); if (tabId) { requestFileNavigation(tabId); - navigateToTabId(tabId); } }, - [ - isMobile, - navigateToTabId, - openInSidePane, - persistenceKey, - requestFileNavigation, - showMobileAgent, - ], + [openInSidePane, persistenceKey, requestFileNavigation], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -1768,7 +1742,6 @@ function WorkspaceScreenContent({ }); if (tabId) { requestFileNavigation(tabId); - navigateToTabId(tabId); } return; } @@ -2483,7 +2456,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: action.target === "files" ? "files" : "changes", }); return true; @@ -2492,7 +2464,6 @@ function WorkspaceScreenContent({ openWorkspacePullRequest({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, destination: pullRequestOpenLocation, }); return true; @@ -2516,7 +2487,6 @@ function WorkspaceScreenContent({ return true; }, [ - activeExplorerCheckout, focusedPaneTabState.pane?.id, isMobile, openWorkspaceTabFocused, @@ -2657,7 +2627,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: "changes", }); } @@ -2667,7 +2636,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: "files", }); } @@ -2678,7 +2646,6 @@ function WorkspaceScreenContent({ }, [ focusedPaneTabState.pane?.id, - activeExplorerCheckout, handleCreateBrowserTab, handleCreateDraftTab, isGitCheckout, @@ -2955,7 +2922,6 @@ function WorkspaceScreenContent({ }), [activeFileLineEnd, activeFileLineStart, activeFilePath], ); - const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const shouldRenderDesktopPaneFallback = useMemo( () => !isMobile && !canRenderDesktopPaneSplits, [isMobile, canRenderDesktopPaneSplits], @@ -3008,7 +2974,6 @@ function WorkspaceScreenContent({ preferences: openInSidePane, }); if (tabId && target.kind === "file") requestFileNavigation(tabId); - if (tabId) navigateToTabId(tabId); }, onOpenTargetToSide: canRenderDesktopPaneSplits && @@ -3022,7 +2987,6 @@ function WorkspaceScreenContent({ parentTabId: input.tab.tabId, }); if (tabId && target.kind === "file") requestFileNavigation(tabId); - if (tabId) navigateToTabId(tabId); } : undefined, onCloseCurrentTab: () => { @@ -3506,7 +3470,6 @@ function WorkspaceScreenContent({ - - - {(topOverlayInset) => ( - - {content} - - )} - - - - + + + {(topOverlayInset) => ( + + {content} + + )} + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} @@ -3650,13 +3605,7 @@ function WorkspaceScreenContent({ ) : null} - - {desktopContent} - + {desktopContent} ); @@ -3667,70 +3616,53 @@ function WorkspaceScreenContent({ [], ); const sharedExplorer = useMemo(() => { - if ( - !workspaceLayout || - !explorerSidebarPaneId || - !persistenceKey || - (!isMobile && canRenderDesktopPaneSplits) - ) - return null; + if (!workspaceLayout || !explorerSidebarPaneId || !persistenceKey) return null; const explorerPane = findPaneById(workspaceLayout.root, explorerSidebarPaneId); if (!explorerPane) return null; const dock = ( - - - + ); - if (isMobile) return {dock}; return ( - {dock} - + ); }, [ workspaceLayout, explorerSidebarPaneId, persistenceKey, isMobile, - canRenderDesktopPaneSplits, - newTabLauncher, - workspacePanelKey, - workspacePanelTabIds, uiTabs, normalizedServerId, normalizedWorkspaceId, @@ -3755,6 +3687,7 @@ function WorkspaceScreenContent({ buildDesktopPaneContentModel, renderExplorerSidebarHeaderAction, workspaceViewportWidth, + desktopFocusModeEnabled, ]); const { revealDirectory } = useFileExplorerActions({ @@ -3770,7 +3703,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: "files", }); }); @@ -3803,13 +3735,30 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} isRouteFocused={isRouteFocused} /> - + - {workspaceCenterColumn} - {sharedExplorer} + + {workspaceCenterColumn} + {sharedExplorer} + - + ({ backgroundColor: theme.colors.surfaceWorkspace, }, threePaneRow: { + // Keep the Explorer overlay below the workspace sheet host on web as well as native. + zIndex: 0, flex: 1, minHeight: 0, flexDirection: "row", diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index b2b226e645..14e7b89b31 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -1,3 +1,7 @@ +import { + WorkspaceHeaderExplorerToggle, + WorkspaceExplorerSidebarToggle, +} from "./workspace-explorer-toggle"; import { describe, expect, it } from "vitest"; import { computeWorkspaceTabLayout, @@ -144,3 +148,25 @@ describe("retainWorkspaceTabMeasuredWidth", () => { expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640); }); }); + +describe("Explorer toggle ownership", () => { + const toggleProps = { + onPress: () => {}, + label: "Close Explorer sidebar", + tooltipLabel: "Toggle Explorer sidebar", + tooltipKeys: [], + accessibilityState: { expanded: true }, + }; + + it("keeps only the header toggle when window controls do not obstruct it", () => { + expect(WorkspaceHeaderExplorerToggle({ ...toggleProps, owner: "header" })).not.toBeNull(); + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "header" })).toBeNull(); + }); + + it("keeps the dock toggle for window-owned and compact overlay chrome", () => { + for (const owner of ["window", "mobile"] as const) { + expect(WorkspaceHeaderExplorerToggle({ ...toggleProps, owner })).toBeNull(); + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner })).not.toBeNull(); + } + }); +}); diff --git a/packages/app/src/stores/explorer-checkout-context.ts b/packages/app/src/stores/explorer-checkout-context.ts deleted file mode 100644 index 926c02d4e0..0000000000 --- a/packages/app/src/stores/explorer-checkout-context.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ExplorerCheckoutContext { - serverId: string; - cwd: string; - isGit: boolean; -} diff --git a/packages/app/src/stores/explorer-tab-memory.ts b/packages/app/src/stores/explorer-tab-memory.ts deleted file mode 100644 index 3e26c0e947..0000000000 --- a/packages/app/src/stores/explorer-tab-memory.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type ExplorerTab = "changes" | "files" | "pr"; - -export function isExplorerTab(value: unknown): value is ExplorerTab { - return value === "changes" || value === "files" || value === "pr"; -} - -export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null { - const trimmedServerId = serverId.trim(); - const trimmedCwd = cwd.trim(); - if (!trimmedServerId || !trimmedCwd) { - return null; - } - return `${trimmedServerId}::${trimmedCwd}`; -} - -export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab { - if (!isGit && tab === "changes") { - return "files"; - } - return tab; -} - -export function resolveExplorerTabForCheckout(params: { - serverId: string; - cwd: string; - isGit: boolean; - explorerTabByCheckout: Record; -}): ExplorerTab { - const key = buildExplorerCheckoutKey(params.serverId, params.cwd); - const stored = key ? params.explorerTabByCheckout[key] : null; - const defaultTab: ExplorerTab = params.isGit ? "changes" : "files"; - const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab; - return coerceExplorerTabForCheckout(nextTab, params.isGit); -} diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 1980594b81..8af964f181 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -2,15 +2,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { - buildExplorerCheckoutKey, - coerceExplorerTabForCheckout, - resolveExplorerTabForCheckout, - type ExplorerTab, -} from "../explorer-tab-memory"; -import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; -import { - buildOpenFileExplorerPatch, - buildToggleFileExplorerPatch, clampTreeRailWidth, clampSidebarWidth, DEFAULT_TREE_RAIL_WIDTH, @@ -32,8 +23,6 @@ import { } from "./state"; import { isWeb } from "@/constants/platform"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; -export type { ExplorerTab } from "../explorer-tab-memory"; -export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; export type { DesktopSidebarState, MobilePanelView, @@ -63,8 +52,6 @@ export interface PanelState { desktop: DesktopSidebarState; // File explorer settings (shared between mobile/desktop) - explorerTab: ExplorerTab; - explorerTabByCheckout: Record; expandedPathsByWorkspace: Record; // Changes-view folder tree. Inverted semantics vs the fields above: // this stores COLLAPSED directory paths (empty = all folders expanded), keyed @@ -93,16 +80,11 @@ export interface PanelState { openAgentListForLayout: (input: PanelLayoutInput) => void; closeAgentListForLayout: (input: PanelLayoutInput) => void; toggleAgentListForLayout: (input: PanelLayoutInput) => void; - openCompactFileExplorer: (checkout: ExplorerCheckoutContext) => void; - toggleCompactFileExplorer: (checkout: ExplorerCheckoutContext) => void; // File explorer settings actions - setExplorerTab: (tab: ExplorerTab) => void; - setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void; setCollapsedFilePathsForWorkspace: (workspaceKey: string, paths: string[]) => void; - activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; setSidebarWidth: (width: number) => void; setExplorerSortOption: (option: SortOption) => void; toggleExplorerShowHiddenFiles: () => void; @@ -133,8 +115,6 @@ export const usePanelStore = create()( }, // File explorer defaults - explorerTab: "changes", - explorerTabByCheckout: {}, expandedPathsByWorkspace: {}, diffCollapsedFoldersByWorkspace: {}, collapsedFilePathsByWorkspace: {}, @@ -224,29 +204,6 @@ export const usePanelStore = create()( }; }), - openCompactFileExplorer: (checkout) => - set((state) => buildOpenFileExplorerPatch(state, checkout)), - - toggleCompactFileExplorer: (checkout) => - set((state) => buildToggleFileExplorerPatch(state, checkout)), - - setExplorerTab: (tab) => set({ explorerTab: tab }), - setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) => - set((state) => { - const resolvedTab = coerceExplorerTabForCheckout(tab, isGit); - const key = buildExplorerCheckoutKey(serverId, cwd); - const nextState: Partial = { explorerTab: resolvedTab }; - if (key) { - const current = state.explorerTabByCheckout[key]; - if (current !== resolvedTab) { - nextState.explorerTabByCheckout = { - ...state.explorerTabByCheckout, - [key]: resolvedTab, - }; - } - } - return nextState; - }), setExpandedPathsForWorkspace: (workspaceKey, paths) => set((state) => { const currentPaths = state.expandedPathsByWorkspace[workspaceKey] ?? ["."]; @@ -272,15 +229,6 @@ export const usePanelStore = create()( [workspaceKey]: paths, }, })), - activateExplorerTabForCheckout: (checkout) => - set((state) => ({ - explorerTab: resolveExplorerTabForCheckout({ - serverId: checkout.serverId, - cwd: checkout.cwd, - isGit: checkout.isGit, - explorerTabByCheckout: state.explorerTabByCheckout, - }), - })), setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), toggleExplorerShowHiddenFiles: () => @@ -290,13 +238,11 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 16, + version: 17, storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), migrate: (persistedState, version) => migratePanelState(persistedState, version), partialize: (state) => ({ desktop: state.desktop, - explorerTab: state.explorerTab, - explorerTabByCheckout: state.explorerTabByCheckout, expandedPathsByWorkspace: state.expandedPathsByWorkspace, diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace, collapsedFilePathsByWorkspace: state.collapsedFilePathsByWorkspace, diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index 19c8d7d17a..ad52b8a8c4 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from "vitest"; import { - buildExplorerCheckoutKey, - resolveExplorerTabForCheckout, - type ExplorerTab, -} from "@/stores/explorer-tab-memory"; -import { - buildOpenFileExplorerPatch, - buildToggleFileExplorerPatch, DEFAULT_TREE_RAIL_WIDTH, migratePanelState, selectIsAgentListOpen, @@ -22,81 +15,10 @@ function makePanelState(overrides: Partial = {}): PanelCoreState agentListOpen: false, focusModeEnabled: false, }, - explorerTab: "changes", - explorerTabByCheckout: {}, ...overrides, }; } -describe("panel-store explorer tab resolution", () => { - const serverId = "server-1"; - const cwd = "/tmp/repo"; - - it("defaults to changes for git checkouts", () => { - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: true, - explorerTabByCheckout: {}, - }), - ).toBe("changes"); - }); - - it("defaults to files for non-git checkouts", () => { - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: false, - explorerTabByCheckout: {}, - }), - ).toBe("files"); - }); - - it("restores a stored files tab for git checkouts", () => { - const key = buildExplorerCheckoutKey(serverId, cwd)!; - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: true, - explorerTabByCheckout: { - [key]: "files", - }, - }), - ).toBe("files"); - }); - - it("falls back to default when stored tab is invalid", () => { - const key = buildExplorerCheckoutKey(serverId, cwd)!; - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: true, - explorerTabByCheckout: { - [key]: "terminals" as unknown as ExplorerTab, - }, - }), - ).toBe("changes"); - }); - - it("coerces stored changes to files for non-git checkouts", () => { - const key = buildExplorerCheckoutKey(serverId, cwd)!; - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: false, - explorerTabByCheckout: { - [key]: "changes", - }, - }), - ).toBe("files"); - }); -}); - describe("panel-store migration", () => { it("defaults hidden-file visibility to showing hidden files", () => { const state = migratePanelState({}, 10); @@ -191,57 +113,12 @@ describe("panel-store visibility selectors", () => { }); }); -describe("panel-store compact file explorer actions", () => { - it("opens the compact explorer and resolves the tab from the explicit checkout", () => { - const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; - const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - const state = makePanelState({ - explorerTab: "changes", - explorerTabByCheckout: { [key]: "files" }, - }); - - const patch = buildOpenFileExplorerPatch(state, checkout); - - expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 }); - expect(patch.explorerTab).toBe("files"); - }); - - it("toggles the explorer closed without changing the active tab", () => { - const state = makePanelState({ - mobilePanel: { target: "file-explorer", revision: 1 }, - explorerTab: "files", - }); - - const patch = buildToggleFileExplorerPatch(state, { - serverId: "server-1", - cwd: "/tmp/repo", - isGit: true, - }); - - expect(patch).toEqual({ mobilePanel: { target: "agent", revision: 2 } }); - }); - - it("coerces changes to files for a non-git checkout", () => { - const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false }; - const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - const state = makePanelState({ - explorerTab: "changes", - explorerTabByCheckout: { [key]: "changes" }, - }); - - expect(buildOpenFileExplorerPatch(state, checkout).explorerTab).toBe("files"); - }); - - it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => { - const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: {} }); - - const patch = buildOpenFileExplorerPatch(state, { - serverId: "server-1", - cwd: "/tmp/non-git", - isGit: false, - }); - - expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 }); - expect(patch.explorerTab).toBe("files"); - }); +it("discards obsolete checkout selection while preserving other panel preferences", () => { + const state = migratePanelState( + { explorerTab: "files", explorerTabByCheckout: { checkout: "changes" }, sidebarWidth: 400 }, + 16, + ); + expect(state).not.toHaveProperty("explorerTab"); + expect(state).not.toHaveProperty("explorerTabByCheckout"); + expect(state.sidebarWidth).toBe(400); }); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index c9b68d2584..cbbead4161 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -1,10 +1,3 @@ -import { - buildExplorerCheckoutKey, - isExplorerTab, - resolveExplorerTabForCheckout, - type ExplorerTab, -} from "../explorer-tab-memory"; -import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; import { z } from "zod"; export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; @@ -36,8 +29,6 @@ export interface PanelLayoutInput { export interface PanelCoreState { mobilePanel: MobilePanelSelection; desktop: DesktopSidebarState; - explorerTab: ExplorerTab; - explorerTabByCheckout: Record; } function clampNumber(value: number, min: number, max: number): number { @@ -77,45 +68,6 @@ export function setMobilePanelTarget( return { target, revision: selection.revision + 1 }; } -function resolveExplorerTabFromCheckout( - state: PanelCoreState, - checkout: ExplorerCheckoutContext, -): ExplorerTab { - return resolveExplorerTabForCheckout({ - serverId: checkout.serverId, - cwd: checkout.cwd, - isGit: checkout.isGit, - explorerTabByCheckout: state.explorerTabByCheckout, - }); -} - -export interface OpenFileExplorerPatch { - mobilePanel: MobilePanelSelection; - explorerTab: ExplorerTab; -} - -export function buildOpenFileExplorerPatch( - state: PanelCoreState, - checkout: ExplorerCheckoutContext, -): OpenFileExplorerPatch { - return { - mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"), - explorerTab: resolveExplorerTabFromCheckout(state, checkout), - }; -} - -export type ToggleFileExplorerPatch = OpenFileExplorerPatch | { mobilePanel: MobilePanelSelection }; - -export function buildToggleFileExplorerPatch( - state: PanelCoreState, - checkout: ExplorerCheckoutContext, -): ToggleFileExplorerPatch { - if (!selectIsCompactFileExplorerOpen(state)) { - return buildOpenFileExplorerPatch(state, checkout); - } - return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") }; -} - const ExplorerTabSchema = z.enum(["changes", "files", "pr"]); const DesktopSidebarStorageSchema = z.strictObject({ agentListOpen: z.boolean().optional(), @@ -156,26 +108,6 @@ export const PanelPersistedStateSchema = z.strictObject({ type MigratablePanelState = z.infer; -function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void { - if ( - version < 4 || - typeof state.explorerTabByCheckout !== "object" || - !state.explorerTabByCheckout - ) { - state.explorerTabByCheckout = {}; - return; - } - const entries = Object.entries(state.explorerTabByCheckout); - const next: Record = {}; - for (const [key, value] of entries) { - if (!isExplorerTab(value)) { - continue; - } - next[key] = value; - } - state.explorerTabByCheckout = next; -} - function migratePanelDesktopFocusMode(state: MigratablePanelState): void { const desktop = state.desktop; if (!desktop) { @@ -216,10 +148,8 @@ export function migratePanelState(persistedState: unknown, version: number): Mig if (state.desktop) { delete state.desktop.fileExplorerOpen; } - if (!isExplorerTab(state.explorerTab)) { - state.explorerTab = "changes"; - } - migratePanelExplorerTabByCheckout(state, version); + delete state.explorerTab; + delete state.explorerTabByCheckout; if (version < 8) { migratePanelDesktopFocusMode(state); } @@ -263,6 +193,3 @@ export function migratePanelState(persistedState: unknown, version: number): Mig return state; } - -export { buildExplorerCheckoutKey, resolveExplorerTabForCheckout }; -export type { ExplorerTab, ExplorerCheckoutContext }; diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index da8d1e010d..01c932605e 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -123,6 +123,10 @@ export interface SheetTab { parentTabId: string | null; } +export type WorkspaceTabDestination = + | { kind: "pane"; paneId: string; host: "main" | "explorer"; tabId: string } + | { kind: "sheet"; tabId: string }; + interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; @@ -161,11 +165,13 @@ interface WorkspaceLayoutStore { resolvePlacement: () => Pick, ) => string | null; /** Reveals the Explorer sidebar without selecting a view. Returns its pane id. */ + ensureExplorerSidebar: (workspaceKey: string) => string | null; showExplorerSidebar: (workspaceKey: string) => string | null; hideExplorerSidebar: (workspaceKey: string) => void; /** Returns the ordinary right-side workspace pane, creating it when absent. */ ensureSidePane: (workspaceKey: string, options?: { focus: boolean }) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; + getTabDestination: (workspaceKey: string, tabId: string) => WorkspaceTabDestination | null; focusTab: (workspaceKey: string, tabId: string) => void; selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void; replaceTab: ( @@ -1104,8 +1110,18 @@ export function createWorkspaceLayoutStore( if (!result) { return null; } + const openedLayout = + get().layoutMode === "compact" && placement.explorerSidebarPaneId + ? (setPaneHiddenInLayout({ + layout: result.layout, + paneId: placement.explorerSidebarPaneId, + hidden: + findPaneById(placement.layout.root, placement.explorerSidebarPaneId)?.hidden === + true, + }) ?? result.layout) + : result.layout; const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar( - result.layout, + openedLayout, placement.explorerSidebarPaneId, placement.layout.focusedPaneId, ); @@ -1178,7 +1194,7 @@ export function createWorkspaceLayoutStore( set(promoted.patch); return promoted.tabId; }, - showExplorerSidebar: (workspaceKey) => { + ensureExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { return null; @@ -1199,14 +1215,11 @@ export function createWorkspaceLayoutStore( state.layoutByWorkspace, normalizedWorkspaceKey, ); - const revealedLayout = - setPaneHiddenInLayout({ layout: currentLayout, paneId, hidden: false }) ?? - currentLayout; return { layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: keepWorkspaceFocusOutOfExplorerSidebar( - revealedLayout, + currentLayout, paneId, currentLayout.focusedPaneId, ), @@ -1219,6 +1232,18 @@ export function createWorkspaceLayoutStore( }); return paneId; }, + showExplorerSidebar: (workspaceKey) => { + const paneId = get().ensureExplorerSidebar(workspaceKey); + if (!paneId || get().layoutMode === "compact") return paneId; + set((state) => { + const layout = state.layoutByWorkspace[workspaceKey]; + const next = setPaneHiddenInLayout({ layout, paneId, hidden: false }); + return next + ? { layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: next } } + : state; + }); + return paneId; + }, hideExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -1370,6 +1395,26 @@ export function createWorkspaceLayoutStore( }; }); }, + getTabDestination: (workspaceKey, tabId) => { + const state = get(); + if (state.sheetsByWorkspace[workspaceKey]?.some((sheet) => sheet.tab.tabId === tabId)) { + return { kind: "sheet", tabId }; + } + const layout = state.layoutByWorkspace[workspaceKey]; + if (!layout) return null; + const pane = findPaneContainingTab(layout.root, tabId); + if (!pane) return null; + const explorerId = resolveExplorerSidebarPaneId( + layout, + state.explorerSidebarPaneIdByWorkspace[workspaceKey], + ); + return { + kind: "pane", + paneId: pane.id, + host: pane.id === explorerId ? "explorer" : "main", + tabId, + }; + }, focusTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId); @@ -1390,7 +1435,7 @@ export function createWorkspaceLayoutStore( setPaneHiddenInLayout({ layout, paneId: explorerSidebarPaneId, - hidden: false, + hidden: state.layoutMode === "compact" ? tabPane.hidden === true : false, }) ?? layout; nextLayout = selectTabInPaneInLayout({ diff --git a/packages/app/src/workspace-tabs/explorer-sidebar.test.ts b/packages/app/src/workspace-tabs/explorer-sidebar.test.ts index a5040ec59d..6d76af4a66 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.test.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.test.ts @@ -23,7 +23,6 @@ import { } from "@/workspace-tabs/explorer-sidebar"; const WORKSPACE_KEY = "server-1:ws-main"; -const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; beforeEach(() => { useWorkspaceLayoutStore.setState({ @@ -34,27 +33,20 @@ beforeEach(() => { }); usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 }, - explorerTab: "files", - explorerTabByCheckout: {}, }); }); describe("Explorer sidebar", () => { it("selects the Explorer shell from layout and split capabilities", () => { expect(resolveExplorerSidebarPresentation({ isCompact: true })).toBe("overlay"); - expect( - resolveExplorerSidebarPresentation({ isCompact: false, supportsPaneSplits: false }), - ).toBe("dock"); - expect(resolveExplorerSidebarPresentation({ isCompact: false, supportsPaneSplits: true })).toBe( - "pane", - ); + expect(resolveExplorerSidebarPresentation({ isCompact: false })).toBe("dock"); + expect(resolveExplorerSidebarPresentation({ isCompact: false })).toBe("dock"); }); it("uses the same Explorer pane inside the compact overlay", () => { openExplorerSidebarView({ isCompact: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, view: "changes", }); @@ -70,9 +62,7 @@ describe("Explorer sidebar", () => { it("creates a dedicated desktop Explorer containing only its requested tree", () => { openExplorerSidebarView({ isCompact: false, - supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, view: "files", }); @@ -86,9 +76,7 @@ describe("Explorer sidebar", () => { it("toggles the desktop Explorer independently of ordinary panes", () => { const input = { isCompact: false, - supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, }; openExplorerSidebarView({ ...input, view: "files" }); toggleExplorerSidebar(input); @@ -109,16 +97,13 @@ describe("Explorer sidebar", () => { }); 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 b2e451265a..26c64e016b 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.ts @@ -1,6 +1,4 @@ -import { supportsDesktopPaneSplits } from "@/constants/layout"; import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; -import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { selectIsExplorerSidebarVisible, useWorkspaceLayoutStore, @@ -8,7 +6,7 @@ import { import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; export type ExplorerSidebarView = "changes" | "files" | "pr"; -export type ExplorerSidebarPresentation = "overlay" | "dock" | "pane"; +export type ExplorerSidebarPresentation = "overlay" | "dock"; const VIEW_TARGETS: Record = { changes: { kind: "changes_tree" }, @@ -19,31 +17,20 @@ const VIEW_TARGETS: Record = { export interface ExplorerSidebarQuery { isCompact: boolean; workspaceKey: string | null; - supportsPaneSplits?: boolean; -} - -export interface ExplorerSidebarInput extends ExplorerSidebarQuery { - checkout: ExplorerCheckoutContext | null; } export function resolveExplorerSidebarPresentation( - input: Pick, + input: Pick, ): ExplorerSidebarPresentation { if (input.isCompact) { return "overlay"; } - return (input.supportsPaneSplits ?? supportsDesktopPaneSplits()) ? "pane" : "dock"; -} - -export function usesCompactExplorerSidebar( - input: Pick, -): boolean { - return resolveExplorerSidebarPresentation(input) !== "pane"; + return "dock"; } /** Reveals the Explorer sidebar and selects one of its navigation trees. */ export function openExplorerSidebarView( - input: ExplorerSidebarInput & { view: ExplorerSidebarView }, + input: ExplorerSidebarQuery & { view: ExplorerSidebarView }, ): void { if (!input.workspaceKey) return; const store = useWorkspaceLayoutStore.getState(); @@ -56,17 +43,20 @@ export function openExplorerSidebarView( }); } -export function showExplorerSidebar(input: ExplorerSidebarInput): string | null { +export function showExplorerSidebar(input: ExplorerSidebarQuery): string | null { if (input.isCompact) { usePanelStore.getState().showMobileFileExplorer(); } if (input.workspaceKey) { - return useWorkspaceLayoutStore.getState().showExplorerSidebar(input.workspaceKey); + const store = useWorkspaceLayoutStore.getState(); + return input.isCompact + ? store.ensureExplorerSidebar(input.workspaceKey) + : store.showExplorerSidebar(input.workspaceKey); } return null; } -export function hideExplorerSidebar(input: ExplorerSidebarInput): void { +export function hideExplorerSidebar(input: ExplorerSidebarQuery): void { if (input.isCompact) { usePanelStore.getState().showMobileAgent(); return; @@ -76,7 +66,7 @@ export function hideExplorerSidebar(input: ExplorerSidebarInput): void { } } -export function toggleExplorerSidebar(input: ExplorerSidebarInput): void { +export function toggleExplorerSidebar(input: ExplorerSidebarQuery): void { if (input.isCompact) { if (isExplorerSidebarOpen(input)) hideExplorerSidebar(input); else showExplorerSidebar(input); diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts new file mode 100644 index 0000000000..ab3d1539ab --- /dev/null +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, expect, it } from "vitest"; +import { + selectIsExplorerSidebarVisible, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { revealWorkspaceTab } from "./navigation"; +import { openWorkspaceTargetAtLocation } from "./open-beside"; + +const workspaceKey = "server:workspace"; +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + }); + usePanelStore.getState().showMobileFileExplorer(); +}); +it("reveals an existing main file selected from compact Explorer", () => { + const store = useWorkspaceLayoutStore.getState(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + const existing = openWorkspaceTargetAtLocation({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + location: "main", + }); + expect(existing).toBe(tabId); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); +}); +it("keeps Explorer beneath a new compact file sheet", () => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + const tabId = openWorkspaceTargetAtLocation({ + workspaceKey, + target: { kind: "file", path: "new.ts" }, + location: "main", + }); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]?.[0].tab.tabId).toBe( + tabId, + ); +}); +it("reveals an explicit Explorer target and preserves the surface after a failed open", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.setLayoutMode("compact"); + usePanelStore.getState().showMobileAgent(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "draft.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + revealWorkspaceTab(workspaceKey, tabId); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + revealWorkspaceTab(workspaceKey, null); + revealWorkspaceTab(workspaceKey, "missing"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); +}); +it("preserves dock visibility intent while compact targets are opened and revealed", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.hideExplorerSidebar(workspaceKey); + store.setLayoutMode("compact"); + store.showExplorerSidebar(workspaceKey); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "overlay-only.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + revealWorkspaceTab(workspaceKey, tabId); + store.setLayoutMode("split-panes"); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).toBeDefined(); + expect(useWorkspaceLayoutStore.getState().getTabDestination(workspaceKey, tabId!)).toMatchObject({ + host: "explorer", + }); + expect(selectIsExplorerSidebarVisible(useWorkspaceLayoutStore.getState(), workspaceKey)).toBe( + false, + ); +}); diff --git a/packages/app/src/workspace-tabs/navigation.ts b/packages/app/src/workspace-tabs/navigation.ts new file mode 100644 index 0000000000..5e36f65102 --- /dev/null +++ b/packages/app/src/workspace-tabs/navigation.ts @@ -0,0 +1,19 @@ +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { usePanelStore } from "@/stores/panel-store"; + +/** Reveals the actual destination after placement and deduplication, including existing tabs. */ +export function revealWorkspaceTab(workspaceKey: string, tabId: string | null): string | null { + if (!tabId) return null; + const store = useWorkspaceLayoutStore.getState(); + const destination = store.getTabDestination(workspaceKey, tabId); + // Opening a sheet already reveals it, preserving the surface underneath. + if (!destination) return null; + if (destination.kind === "sheet") return tabId; + store.focusTab(workspaceKey, tabId); + if (store.layoutMode === "compact") { + const panels = usePanelStore.getState(); + if (destination.host === "explorer") panels.showMobileFileExplorer(); + else panels.showMobileAgent(); + } + return tabId; +} diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index 0c0c9ebdf5..abf6eba2ec 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -1,3 +1,4 @@ +import { revealWorkspaceTab } from "./navigation"; import type { OpenInSidePanePreferences } from "@/hooks/use-settings"; import { collectAllTabs, @@ -117,13 +118,16 @@ export function openWorkspaceTargetAtLocation( const parentPane = findPaneContainingTab(layout.root, input.parentTabId); placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined; } - return store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement, - parentTabId: input.parentTabId ?? undefined, - }); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement, + parentTabId: input.parentTabId ?? undefined, + }), + ); } /** Opens a tree selection while reusing an unmodified preview in its destination pane. */ @@ -170,15 +174,21 @@ export function openPreferredWorkspacePreview( nextTarget: input.target, }) ) { - return store.replaceTab(input.workspaceKey, activeTab.tabId, input.target); + return revealWorkspaceTab( + input.workspaceKey, + store.replaceTab(input.workspaceKey, activeTab.tabId, input.target), + ); } - return store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement: { mode: "pane", paneId: destinationPaneId }, - parentTabId: input.parentTabId ?? activeTab?.tabId ?? undefined, - }); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: { mode: "pane", paneId: destinationPaneId }, + parentTabId: input.parentTabId ?? activeTab?.tabId ?? undefined, + }), + ); } /** @@ -192,11 +202,14 @@ export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): stri } const store = useWorkspaceLayoutStore.getState(); const paneId = store.ensureSidePane(input.workspaceKey); - return store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement: paneId ? { mode: "pane", paneId } : undefined, - parentTabId: input.parentTabId ?? undefined, - }); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: paneId ? { mode: "pane", paneId } : undefined, + parentTabId: input.parentTabId ?? undefined, + }), + ); } 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 e3aa7c7f01..0e423b02c3 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.test.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.test.ts @@ -24,7 +24,6 @@ import { } from "@/workspace-tabs/open-supporting-view"; const WORKSPACE_KEY = "server-1:workspace-1"; -const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; function activeExplorerKind() { const state = useWorkspaceLayoutStore.getState(); @@ -37,10 +36,10 @@ function activeExplorerKind() { beforeEach(() => { usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 }, - explorerTab: "files", - explorerTabByCheckout: {}, }); useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + sheetsByWorkspace: {}, pullRequestTabAutoOpenedByWorkspace: {}, layoutByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, @@ -51,10 +50,10 @@ beforeEach(() => { describe("openWorkspaceChanges", () => { it("opens Changes in the compact Explorer", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); openWorkspaceChanges({ isCompact: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, preferences: DEFAULT_APP_SETTINGS.openInSidePane, }); @@ -68,7 +67,6 @@ describe("openComposerChanges", () => { isCompact: false, supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, preferences: DEFAULT_APP_SETTINGS.openInSidePane, }; @@ -114,6 +112,7 @@ describe("openComposerChanges", () => { }); it("keeps opening the compact Explorer on Changes", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); openComposerChanges({ ...input, isCompact: true }); openComposerChanges({ ...input, isCompact: true }); @@ -127,12 +126,12 @@ describe("openWorkspacePullRequest", () => { 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) => { + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); openWorkspacePullRequest({ ...input, isCompact: true, destination }); expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index bb567d4bd0..73ae55c31f 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -1,3 +1,5 @@ +import { supportsDesktopPaneSplits } from "@/constants/layout"; +import { revealWorkspaceTab } from "./navigation"; import { collectAllPanes, collectAllTabs, @@ -8,11 +10,9 @@ import { useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; 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 { @@ -23,7 +23,6 @@ import { interface WorkspaceViewInput { isCompact: boolean; workspaceKey: string | null; - checkout: ExplorerCheckoutContext | null; supportsPaneSplits?: boolean; } @@ -35,13 +34,17 @@ interface OpenWorkspacePullRequestInput extends WorkspaceViewInput { destination: PullRequestOpenLocation; } +function supportingViewsBelongInExplorer(input: WorkspaceViewInput): boolean { + return input.isCompact || !(input.supportsPaneSplits ?? supportsDesktopPaneSplits()); +} + 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)) { + if (supportingViewsBelongInExplorer(input)) { openExplorerView(input, "changes"); return null; } @@ -55,7 +58,7 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | /** 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)) { + if (supportingViewsBelongInExplorer(input) || isExplorerSidebarOpen(input)) { return openWorkspaceChanges(input); } openExplorerView(input, "changes"); @@ -64,15 +67,26 @@ export function openComposerChanges(input: OpenWorkspaceChangesInput): string | /** 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; + if (!input.workspaceKey) return null; + let tabId: string | null; + if (supportingViewsBelongInExplorer(input) || input.destination === "explorer") { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.ensureExplorerSidebar(input.workspaceKey); + tabId = store.openTab({ + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + intent: "reveal", + placement: paneId ? { mode: "prefer", paneId } : undefined, + }); + } else { + return openWorkspaceTargetAtLocation({ + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + location: input.destination, + }); } - return openWorkspaceTargetAtLocation({ - workspaceKey: input.workspaceKey, - target: { kind: "pull_request" }, - location: input.destination, - }); + revealWorkspaceTab(input.workspaceKey, tabId); + return tabId; } /** Adds a detected desktop PR once without revealing or selecting its destination. */