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.
This commit is contained in:
Mohamed Boudra
2026-10-19 16:57:57 +02:00
parent 49954e1e22
commit a264f41d4a
71 changed files with 2365 additions and 1748 deletions
+27 -8
View File
@@ -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
@@ -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();
}
});
}
+27 -4
View File
@@ -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;
}
+241 -11
View File
@@ -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<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar
explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }),
).toBeInViewport({ ratio: 1 });
}
async function cycleWorkspaceTheme(page: Parameters<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar>[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();
}
});
-5
View File
@@ -445,11 +445,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
});
+24 -21
View File
@@ -44,7 +44,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
@@ -93,7 +93,6 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -480,10 +479,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation === "overlay";
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
@@ -566,24 +561,20 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
[insets.left, insets.right, isCompactLayout],
);
const workspaceChrome = (
<View style={workspaceChromeStyle} onTouchStart={(e) => console.log("DUO_INPUT", e.nativeEvent.pageX, e.nativeEvent.pageY)}>
<View style={workspaceChromeStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
{usesCompactExplorerHost ? (
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</CompactExplorerSidebarHost>
) : (
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
<WindowChromeRegion
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
)}
</WorkspaceExplorerOpenGesture>
</View>
);
@@ -634,15 +625,27 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</View>
);
const content = isCompactLayout ? (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
) : (
surface
return (
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
{surface}
</AppContainerLifetime>
);
}
function AppContainerLifetime({
children,
compactGesturesEnabled,
}: {
children: ReactNode;
compactGesturesEnabled: boolean;
}) {
return (
<ComposerEditingSessionsProvider>
<CommandCenterProvider>{content}</CommandCenterProvider>
<CommandCenterProvider>
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
{children}
</MobileGestureWrapper>
</CommandCenterProvider>
</ComposerEditingSessionsProvider>
);
}
@@ -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 = (
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<ThemedStack
screenOptions={HOST_STACK_SCREEN_OPTIONS}
screenOwnedAppearance={SCREEN_OWNED_APPEARANCE}
>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
@@ -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);
},
);
});
@@ -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 (
<RetainedPanelActivity active={isActive}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
{children}
</MobilePanelOverlay>
</RetainedPanelActivity>
);
}
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 (
<RetainedPanelActivity active={isOpen}>
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
<View style={styles.nativeDockContent}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="native-explorer-sidebar-resize-handle"
/>
{children}
</View>
</Animated.View>
</RetainedPanelActivity>
);
}
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,
}));
@@ -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 (
+67 -653
View File
@@ -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<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
): 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<string, SplitPane>;
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<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(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<number | null>(
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<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
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 (
<RenderProfile id="SplitContainer">
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
) : null}
</View>
</WindowChromeRegion>
{renderExplorerSidebarDock && explorerSidebarPane ? (
<>
<ResizeHandle
testID="workspace-explorer-sidebar-resize-handle"
direction="horizontal"
hitAreaAlignment="end"
groupId={EXPLORER_SIDEBAR_RESIZE_GROUP_ID}
index={0}
sizes={explorerSidebarDockSizes}
containerSize={workspaceShellWidth}
onPreviewResizeSplit={previewExplorerSidebarResize}
onResizeSplit={commitExplorerSidebarResize}
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
<View style={explorerSidebarDockStyle}>
<ExplorerSidebarDock
pane={explorerSidebarPane}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
closingTabIds={closingTabIds}
onSelectTab={onSelectTabInPane}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onExitFocusMode={onExitFocusMode}
buildPaneContentModel={buildPaneContentModel}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
tabDropPreview={tabDropPreview}
headerAction={renderExplorerSidebarHeaderAction?.()}
/>
</View>
</>
) : null}
</View>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
</RenderProfile>
);
}
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<WorkspaceTabDescriptor | null>(
() =>
tab
? {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
}
: null,
[tab],
);
if (!descriptor) {
return null;
}
return (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
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 (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return (
<View style={chipStyle}>
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
<Text numberOfLines={1} style={chipLabelStyle}>
{label}
</Text>
) : null}
</View>
);
}}
</WorkspaceTabPresentationResolver>
</WindowChromeRegion>
</View>
</RenderProfile>
);
}
@@ -1317,21 +766,6 @@ const SplitPaneView = memo(function SplitPaneView({
);
});
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
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,
},
}));
@@ -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);
}
@@ -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)
);
}
@@ -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<typeof WebScope>) {
return children;
}
@@ -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<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
): 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<string, SplitPane>;
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<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(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<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
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 (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<WorkspaceTabDragContext value={context}>{children}</WorkspaceTabDragContext>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
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<WorkspaceTabDescriptor | null>(
() =>
tab
? {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
}
: null,
[tab],
);
if (!descriptor) {
return null;
}
return (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
function DragOverlayTabChipInner({
tab,
normalizedServerId,
normalizedWorkspaceId,
}: {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { t } = useTranslation();
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return (
<View style={styles.dragOverlayChip}>
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
<Text numberOfLines={1} style={styles.dragOverlayLabel}>
{label}
</Text>
</View>
);
}}
</WorkspaceTabPresentationResolver>
);
}
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,
},
}));
@@ -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 {
+22 -49
View File
@@ -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 (
<FileSourceView
<ThemedFileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
@@ -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 ? (
<FileEditorView
<ThemedFileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
+1 -2
View File
@@ -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,
@@ -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<DaemonClient, "checkoutPrStatus" | "pullRequestTimeline"> | 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 <ConnectionContext value={connection}>{children}</ConnectionContext>;
}
export function usePullRequestConnection(serverId: string): PullRequestConnection {
const supplied = useContext(ConnectionContext);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
return supplied ? supplied(serverId) : { client, isConnected };
}
@@ -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);
});
@@ -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);
+3 -5
View File
@@ -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<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
-1
View File
@@ -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";
@@ -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,
);
});
});
@@ -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;
@@ -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 (
+13 -13
View File
@@ -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<RefObject<
GestureType | undefined
> | null>(null);
export function useCloseFileExplorerGesture(enabled = true) {
const rightCloseGestureRef = useRef<GestureType | undefined>(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);
}
+32 -15
View File
@@ -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<typeof Animated.View>["style"];
panelStyle?: ComponentProps<typeof Animated.View>["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({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
{/* Fabric needs an always-mounted native host to attach the close handler while the
retained panel content is hidden. nativeID keeps that host registered. */}
<View
<Animated.View
accessibilityElementsHidden={!isOpen}
collapsable={false}
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
nativeID={`${panel}-gesture-host`}
nativeID={`${panel}-gesture-host-${hostId}`}
pointerEvents={overlayPointerEvents}
style={styles.overlay}
style={[
docked ? styles.dock : styles.overlay,
frameStyle,
docked && !dockVisible && { display: "none" },
]}
>
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
<Animated.View style={[docked ? styles.dockedPanel : styles.overlay, overlayAnimatedStyle]}>
<Pressable
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFill}
style={[StyleSheet.absoluteFill, docked && { display: "none" }]}
testID={isOpen ? `${panel}-backdrop` : undefined}
>
<Animated.View pointerEvents="none" style={backdropStyle} />
@@ -104,7 +119,7 @@ export function MobilePanelOverlay({
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
</Animated.View>
</Animated.View>
</View>
</Animated.View>
</GestureDetector>
);
}
@@ -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,
@@ -49,7 +49,6 @@ interface MobilePanelsRuntime {
motionState: SharedValue<MobilePanelMotionState>;
openGesturesBlocked: SharedValue<boolean>;
position: SharedValue<number>;
rightCloseGestureRef: RefObject<GestureType | undefined>;
rightOpenGestureRef: RefObject<GestureType | undefined>;
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<GestureType | undefined>(undefined);
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(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,
+10 -2
View File
@@ -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<NativeStackNavigationOptions>(
() => ({
@@ -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 <AppearanceStyleBoundary>{content}</AppearanceStyleBoundary>;
},
[nestedNavigatorScreens],
[nestedNavigatorScreens, screenOwnedAppearance],
);
return (
+1 -3
View File
@@ -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(
+1 -4
View File
@@ -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 &&
@@ -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",
);
},
);
@@ -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";
}
+9 -10
View File
@@ -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 <PanelState message={t("panels.diff.directoryMissing")} />;
}
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}`;
+8 -34
View File
@@ -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 (
<View style={CENTERED_PADDED_STYLE}>
@@ -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<typeof createRoot>[] = [];
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 <span data-testid="content">{data.data?.title}</span>;
}
function Rail() {
const descriptor = usePullRequestPanelDescriptor(
{ kind: "pull_request" },
{ serverId, workspaceId },
);
return <span>{descriptor.label}</span>;
}
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<PullRequestConnection["client"]> = {
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 (
<QueryClientProvider client={queryClient}>
<PullRequestConnectionProvider connection={connection}>
<ToastApiProvider api={toastApi}>
<Discovery active={surface} workspaceActive={workspaceActive} />
</ToastApiProvider>
<RetainedPanelActivity active={surface && workspaceActive}>
<Rail />
<RetainedPanelActivity active={content}>
<Content />
</RetainedPanelActivity>
</RetainedPanelActivity>
{otherRail ? <Rail /> : null}
</PullRequestConnectionProvider>
</QueryClientProvider>
);
}
const tabs = () =>
collectAllTabs(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root);
const view = render(<Tree surface={false} content={false} />);
expect(source.calls).toEqual({ status: 0, timeline: 0 });
view.rerender(<Tree surface={true} content={false} />);
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(<Tree surface={true} content={true} />);
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(<Tree surface={false} content={false} connected={false} />);
await act(async () => {
onlineManager.setOnline(false);
await queryClient.invalidateQueries();
onlineManager.setOnline(true);
});
view.rerender(<Tree surface={false} content={false} />);
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(<Tree surface={false} content={false} otherRail />);
await waitFor(() => expect(source.calls).toEqual({ status: 2, timeline: 1 }));
view.rerender(<Tree surface={true} content={true} />);
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(<Tree surface={false} content={false} />);
await act(async () => {
await queryClient.invalidateQueries();
});
view.rerender(<Tree surface={true} content={false} />);
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(<Tree surface content workspaceActive={false} />);
await act(async () => {
await queryClient.invalidateQueries();
});
expect(source.calls).toEqual({ status: 3, timeline: 2 });
view.rerender(<Tree surface content />);
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();
});
@@ -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<void>((resolve) => setTimeout(resolve, 0));
expect(calls).toEqual({ status: 2, timeline: 1 });
timeline.setOptions(options("timeline", true, "content"));
await new Promise<void>((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,
);
},
);
@@ -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);
}
@@ -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) });
}
@@ -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,
};
}
@@ -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]);
}
+8 -48
View File
@@ -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,
@@ -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;
}
@@ -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 (
<span>
{local}:{value}
</span>
);
}
const render = (active: boolean) =>
act(() =>
root.render(
<RetainedPanelActivity active={active}>
<Chat>
<Content />
</Chat>
</RetainedPanelActivity>,
),
);
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<string> }) {
const { mountedTabIds } = useMountedTabSet({
activeTabId: active,
allTabIds,
retainedTabIds: modified,
cap: 3,
});
mounted = [...mountedTabIds];
return null;
}
const render = (active: string, modified = new Set<string>()) =>
act(() => root.render(<Host active={active} modified={modified} />));
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"]);
});
@@ -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";
+9 -6
View File
@@ -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) {
@@ -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 };
}
@@ -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<string>;
activeDragTabId: string | null;
tabDropPreview: TabDropPreview | null;
onSelectTab: (paneId: string, tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | 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}
@@ -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;
}
@@ -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 (
<RetainedPanelActivity active={active}>
<FileExplorerCloseGestureContext value={gestureRef}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={gesture}
presentation={presentation}
dockVisible={isOpen}
frameStyle={geometry.frameStyle}
panelStyle={[{ backgroundColor: surfaceColor }, geometry.panelStyle]}
>
<View style={styles.content}>
{resizeEnabled ? geometry.resizeHandle : null}
{children}
</View>
</MobilePanelOverlay>
</FileExplorerCloseGestureContext>
</RetainedPanelActivity>
);
}
export const ExplorerFrame = withUnistyles(ExplorerFrameBase, (theme) => ({
surfaceColor: theme.colors.surfaceSidebar,
}));
const styles = StyleSheet.create({ content: { flex: 1, minHeight: 0 } });
@@ -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<typeof MobilePanelOverlay>["frameStyle"];
panelStyle: StyleProp<ViewStyle>;
resizeHandle: ReactNode;
}
@@ -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: (
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="native-explorer-sidebar-resize-handle"
/>
),
};
}
@@ -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<number | null>(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: (
<View style={styles.resize}>
<ResizeHandle
testID="workspace-explorer-sidebar-resize-handle"
direction="horizontal"
hitAreaAlignment="end"
groupId="explorer-sidebar"
index={0}
sizes={sizes}
containerSize={containerWidth}
onPreviewResizeSplit={preview}
onResizeSplit={commit}
/>
</View>
),
};
}
const styles = StyleSheet.create({
resize: { position: "absolute", left: 0, top: 0, bottom: 0, zIndex: 2 },
});
@@ -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}
@@ -89,8 +89,9 @@ export function WorkspaceHeaderExplorerToggle({
}
export function WorkspaceExplorerSidebarToggle({
owner: _owner,
owner,
...toggleProps
}: DesktopWorkspaceExplorerToggleProps) {
if (owner === "header") return null;
return <WorkspaceExplorerToggle {...toggleProps} mobile={false} />;
}
@@ -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 (
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
<NewTabLauncherProvider value={launcher}>{children}</NewTabLauncherProvider>
<NewTabLauncherProvider value={launcher}>
<SheetHostScope>
<View style={styles.threePaneRow} onLayout={onLayout}>
{children}
</View>
</SheetHostScope>
</NewTabLauncherProvider>
</WorkspaceComposerEditingSessionsProvider>
);
}
@@ -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<ExplorerCheckoutContext | null>(() => {
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({
<SplitContainer
layout={workspaceLayout}
renderMainHeader={renderWorkspaceScreenHeader}
renderExplorerSidebarHeaderAction={renderExplorerSidebarHeaderAction}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
workspaceKey={persistenceKey}
@@ -3543,7 +3506,6 @@ function WorkspaceScreenContent({
canRenderDesktopPaneSplits,
workspaceLayout,
renderWorkspaceScreenHeader,
renderExplorerSidebarHeaderAction,
persistenceKey,
desktopFocusModeEnabled,
toggleFocusMode,
@@ -3588,35 +3550,28 @@ function WorkspaceScreenContent({
composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId),
});
// Sheets opened from the compact column present inside it, so their panels read the workspace
// providers, and they cover the compact header as a modal.
// Compact sheets use the shared workspace scope, above both center and Explorer.
const workspaceCenterColumn = isMobile ? (
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<SheetHostScope style={styles.centerColumn}>
<FloatingHeaderLayout
header={compactHeaderLayout.header}
floating={compactHeaderLayout.floating}
>
{(topOverlayInset) => (
<StreamTopOverlayProvider value={topOverlayInset}>
<View style={styles.content}>{content}</View>
</StreamTopOverlayProvider>
)}
</FloatingHeaderLayout>
<SheetTabs
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</SheetHostScope>
</WorkspacePanelProviders>
<View style={styles.centerColumn}>
<FloatingHeaderLayout
header={compactHeaderLayout.header}
floating={compactHeaderLayout.floating}
>
{(topOverlayInset) => (
<StreamTopOverlayProvider value={topOverlayInset}>
<View style={styles.content}>{content}</View>
</StreamTopOverlayProvider>
)}
</FloatingHeaderLayout>
<SheetTabs
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</View>
) : (
<View style={styles.centerColumn}>
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
@@ -3650,13 +3605,7 @@ function WorkspaceScreenContent({
) : null}
<View style={styles.centerContent}>
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<View style={styles.content}>{desktopContent}</View>
</WorkspacePanelProviders>
<View style={styles.content}>{desktopContent}</View>
</View>
</View>
);
@@ -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 = (
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<ExplorerSidebarDock
pane={explorerPane}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused && isExplorerSidebarShowing}
closingTabIds={closingTabIds}
activeDragTabId={null}
tabDropPreview={null}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onSelectTab={selectTabInPane}
onCloseTab={handleCloseTabById}
onCreateNewTab={handleCreateNewTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane}
onExitFocusMode={toggleFocusMode}
onReorderTabsInPane={handleReorderTabsInPane}
buildPaneContentModel={buildDesktopPaneContentModel}
headerAction={renderExplorerSidebarHeaderAction()}
/>
</WorkspacePanelProviders>
<ExplorerSidebarDock
pane={explorerPane}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
closingTabIds={closingTabIds}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onSelectTab={selectTabInPane}
onCloseTab={handleCloseTabById}
onCreateNewTab={handleCreateNewTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane}
onExitFocusMode={toggleFocusMode}
onReorderTabsInPane={handleReorderTabsInPane}
buildPaneContentModel={buildDesktopPaneContentModel}
headerAction={renderExplorerSidebarHeaderAction()}
/>
);
if (isMobile) return <CompactExplorerSidebar>{dock}</CompactExplorerSidebar>;
return (
<NativeExplorerSidebarDock
<ExplorerFrame
presentation={isMobile ? "overlay" : "dock"}
isWorkspaceActive={isRouteFocused}
persistenceKey={persistenceKey}
containerWidth={workspaceViewportWidth}
isOpen={isExplorerSidebarShowing}
isOpen={isExplorerSidebarShowing && !desktopFocusModeEnabled}
>
{dock}
</NativeExplorerSidebarDock>
</ExplorerFrame>
);
}, [
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}
/>
<View style={styles.threePaneRow} onLayout={measureWorkspaceWidth}>
<WorkspacePanelSurface
onLayout={measureWorkspaceWidth}
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
{sharedExplorer}
<WorkspaceTabDragScope
layout={workspaceLayout}
explorerSidebarPaneId={explorerSidebarPaneId}
allowCrossPane={layoutMode === "split-panes"}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
uiTabs={uiTabs}
onMoveTabToPane={handleMoveTabToPane}
onReorderTabsInPane={handleReorderTabsInPane}
onSplitPane={handleSplitPane}
>
{workspaceCenterColumn}
{sharedExplorer}
</WorkspaceTabDragScope>
</FloatingPanelPortalHostNameProvider>
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
</View>
</WorkspacePanelSurface>
<ImportSessionSheet
visible={isRouteFocused && isImportSheetVisible}
client={client}
@@ -3856,6 +3805,8 @@ const styles = StyleSheet.create((theme) => ({
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",
@@ -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();
}
});
});
@@ -1,5 +0,0 @@
export interface ExplorerCheckoutContext {
serverId: string;
cwd: string;
isGit: boolean;
}
@@ -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<string, ExplorerTab>;
}): 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);
}
+1 -55
View File
@@ -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<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
// 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<PanelState>()(
},
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
collapsedFilePathsByWorkspace: {},
@@ -224,29 +204,6 @@ export const usePanelStore = create<PanelState>()(
};
}),
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<PanelState> = { 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<PanelState>()(
[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<PanelState>()(
}),
{
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,
@@ -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> = {}): 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);
});
+2 -75
View File
@@ -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<string, ExplorerTab>;
}
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<typeof PanelPersistedStateSchema>;
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<string, ExplorerTab> = {};
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 };
@@ -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<OpenWorkspaceTabInput, "placement" | "insertionPosition">,
) => 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({
@@ -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");
});
});
@@ -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<ExplorerSidebarView, WorkspaceTabTarget> = {
changes: { kind: "changes_tree" },
@@ -19,31 +17,20 @@ const VIEW_TARGETS: Record<ExplorerSidebarView, WorkspaceTabTarget> = {
export interface ExplorerSidebarQuery {
isCompact: boolean;
workspaceKey: string | null;
supportsPaneSplits?: boolean;
}
export interface ExplorerSidebarInput extends ExplorerSidebarQuery {
checkout: ExplorerCheckoutContext | null;
}
export function resolveExplorerSidebarPresentation(
input: Pick<ExplorerSidebarQuery, "isCompact" | "supportsPaneSplits">,
input: Pick<ExplorerSidebarQuery, "isCompact">,
): ExplorerSidebarPresentation {
if (input.isCompact) {
return "overlay";
}
return (input.supportsPaneSplits ?? supportsDesktopPaneSplits()) ? "pane" : "dock";
}
export function usesCompactExplorerSidebar(
input: Pick<ExplorerSidebarQuery, "isCompact" | "supportsPaneSplits">,
): 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);
@@ -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,
);
});
@@ -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;
}
+35 -22
View File
@@ -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,
}),
);
}
@@ -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");
@@ -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. */