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
@@ -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. */