mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
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:
@@ -11,7 +11,7 @@ contracts.
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
@@ -21,7 +21,10 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
|
||||
close-gesture lifetime. Platform adapters own resizing and inset geometry.
|
||||
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
|
||||
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
|
||||
|
||||
## Sheets
|
||||
|
||||
@@ -40,11 +43,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
@@ -56,8 +59,12 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
One Explorer host stays beside the center column across compact overlays and docks. Keep its
|
||||
providers and gesture roots at the same React position when width or appearance changes. The
|
||||
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
|
||||
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
|
||||
Ordinary leaf routes retain the stack appearance policy.
|
||||
The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
never read or modify that width.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
@@ -80,7 +87,8 @@ Other tabs reveal the close control on hover. The + menu opens compatible panels
|
||||
Web also supports dragging agents and terminals into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus.
|
||||
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
|
||||
accept only reordering inside the source pane.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
|
||||
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
|
||||
@@ -94,7 +102,18 @@ narrow columns; widening restores navigation without changing its saved open sta
|
||||
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
|
||||
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
|
||||
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
|
||||
the sheet policy.
|
||||
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
|
||||
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
|
||||
actual destination before revealing it, including deduplicated targets.
|
||||
|
||||
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
|
||||
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
|
||||
up to ten with its existing inactive TTL.
|
||||
|
||||
PR discovery belongs to the active workspace: split layouts discover while active, and other
|
||||
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
|
||||
content also requests timeline data. Inactive consumers read cached data without request demand.
|
||||
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
|
||||
|
||||
## Side pane
|
||||
|
||||
|
||||
@@ -2,48 +2,56 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
for (const width of [1400, 390]) {
|
||||
test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
if (width < 600) await openChangesTreePanel(page);
|
||||
else await openChangesPanel(page);
|
||||
const changedFile = page.getByTestId("diff-file-0-toggle");
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
if (width < 600)
|
||||
await expect(
|
||||
page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
if (width >= 600) await expect(agentComposer).toBeFocused();
|
||||
else await expect(agentComposer).toBeEditable();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
+1
-2
@@ -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 (
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user