feat(app): make the side panel user-directed (#3605)
* feat(app): let the file panel close its tree rail The file panel's tree rail was permanent on desktop — the changes panel had a folder-tree toggle in its toolbar, the file toolbar had nothing, so the rail was there whether you wanted it or not. The changes panel's private DesktopTreeToggle becomes a shared TreeRailToggle so both toolbars render the same affordance; that also drops the treeToggleStyle prop threaded through ChangesToolbarProps, which recomputed exactly what the component already built internally. The i18n keys move out of workspace.git.diff into workspace.tree, since a shared component reaching into the git namespace would rot. The two rails keep independent flags — the file rail in panel-store, the changes rail in useChangesPreferences — so closing one leaves the other alone. FileTreeToggle wires itself to the store rather than taking a callback: the bar renders several components below FilePanel, where the rail actually lives. Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum). The persist version bump resets treeRailWidth on purpose: nearly every existing install carries the old 320 default, so without the reset the narrower rail would only ever reach new users. Anyone who dragged their rail to a deliberate custom width loses it once. Note that MIN_CONTENT_WIDTH still clamps the rail to `containerWidth - 240`, so in a narrow pane the new default never applies — that floor, not the default, is what sets the width there. * feat(app): make the side panel an empty workspace surface The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference. * test(app): exercise side panel policy through its capability boundary The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module. * test(app): expect an empty side panel after upgrade * test(app): expect an empty first split pane * test(app): account for the empty side panel in split retention * feat(app): make workspace panel tabs independent Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions. * fix(app): preserve file conflicts during preview refresh Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
This commit is contained in:
@@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||||
|
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
|
|||||||
+2
-2
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
|||||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||||
|
|
||||||
For the desktop explorer toggle, run the app against the root checkout's daemon and use:
|
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||||
```
|
```
|
||||||
|
|
||||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
|
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
|
||||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||||
|
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Side panel
|
||||||
|
|
||||||
|
The companion surface beside the user's work: Changes, Files, the pull request, and
|
||||||
|
anything an agent asks to show alongside its output.
|
||||||
|
|
||||||
|
Everything that opens, reveals, hides, or asks about it goes through
|
||||||
|
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
||||||
|
surface the current layout uses.
|
||||||
|
|
||||||
|
## Three renderings, two facts
|
||||||
|
|
||||||
|
| Layout | Rendering |
|
||||||
|
| ---------------------- | ----------------------------------------------- |
|
||||||
|
| compact | slide-over overlay, owned by the panel store |
|
||||||
|
| non-compact + splits | dedicated right pane, owned by the layout store |
|
||||||
|
| non-compact, no splits | ordinary tabs in the focused pane |
|
||||||
|
|
||||||
|
The third case is the one that bites. A native tablet renders one pane at a time and
|
||||||
|
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
||||||
|
side panel pane there disappears with no way back.
|
||||||
|
|
||||||
|
## Placement
|
||||||
|
|
||||||
|
Where a tab lands is an intent the caller declares, not something the store infers
|
||||||
|
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
||||||
|
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
||||||
|
difference between them only shows up when the tab is already open somewhere:
|
||||||
|
|
||||||
|
| Mode | New tab | Tab already open |
|
||||||
|
| --------- | ----------------- | ---------------------------- |
|
||||||
|
| `pane` | opens in the pane | **moves** to the pane |
|
||||||
|
| `prefer` | opens in the pane | stays where the user left it |
|
||||||
|
| `focused` | focused pane | focuses it in place |
|
||||||
|
| `ambient` | focused pane | focuses it in place |
|
||||||
|
|
||||||
|
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
||||||
|
The user is placing the tab and expects it to arrive.
|
||||||
|
|
||||||
|
`prefer` is an implicit supporting open — an agent's file link, workspace setup
|
||||||
|
progress. It has an opinion about new tabs only. Yanking a tab out of the pane the
|
||||||
|
user deliberately moved it to is the failure this mode exists to prevent.
|
||||||
|
|
||||||
|
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
||||||
|
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
||||||
|
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
||||||
|
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
||||||
|
is, because the user is standing in it.
|
||||||
|
|
||||||
|
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
||||||
|
every caller. Do not re-place a tab after opening it.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
||||||
|
it empties or the user dismisses it:
|
||||||
|
|
||||||
|
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
||||||
|
- **Close pane** on any other pane **removes** it.
|
||||||
|
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
||||||
|
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
||||||
|
visible.
|
||||||
|
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
||||||
|
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
||||||
|
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
||||||
|
`setPaneHiddenInLayout`; do not add a second one upstream.
|
||||||
|
|
||||||
|
`closePane` in the layout store owns that distinction. React renders the button and
|
||||||
|
does not decide what it means.
|
||||||
|
|
||||||
|
Dragging a pane's last tab into another pane collapses the source pane — except the
|
||||||
|
side panel, which the user summons and expects to find again, split size and all.
|
||||||
|
|
||||||
|
## What the side panel never does
|
||||||
|
|
||||||
|
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
||||||
|
picks what goes in. Detecting a pull request makes the launcher's card available and
|
||||||
|
opens nothing. Event-driven surfaces such as workspace setup progress still add a
|
||||||
|
background tab, which never reveals the panel or moves focus.
|
||||||
|
|
||||||
|
## Routing preference
|
||||||
|
|
||||||
|
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
||||||
|
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||||
|
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
||||||
|
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||||
|
to it.
|
||||||
|
|
||||||
|
## The name
|
||||||
|
|
||||||
|
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
||||||
|
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
||||||
|
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
||||||
|
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
||||||
|
either would fail every saved blob and wipe the layout it describes.
|
||||||
@@ -648,7 +648,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
|
|
||||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
@@ -658,6 +658,41 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
|||||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
// The tree toggle is the one icon action that stays outside the overflow menu.
|
||||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
||||||
|
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
||||||
|
await Promise.all([
|
||||||
|
page.getByTestId("changes-header").boundingBox(),
|
||||||
|
page.getByTestId("changes-toggle-tree").boundingBox(),
|
||||||
|
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
||||||
|
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
||||||
|
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
||||||
|
throw new Error("Changes toolbar geometry could not be measured");
|
||||||
|
}
|
||||||
|
expect(changesToolbarBox.height).toBe(36);
|
||||||
|
expect(treeToggleBox.width).toBe(24);
|
||||||
|
expect(treeToggleBox.height).toBe(24);
|
||||||
|
expect(optionsBox.width).toBe(24);
|
||||||
|
expect(optionsBox.height).toBe(24);
|
||||||
|
expect(treeGlyphBox.width).toBe(14);
|
||||||
|
expect(treeGlyphBox.height).toBe(14);
|
||||||
|
expect(optionsGlyphBox.width).toBe(14);
|
||||||
|
expect(optionsGlyphBox.height).toBe(14);
|
||||||
|
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
||||||
|
const diffOptionsRestBackground = await diffOptions.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
await diffOptions.hover();
|
||||||
|
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
||||||
|
await page.getByTestId("changes-toggle-tree").hover();
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId("changes-toggle-tree")
|
||||||
|
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||||
|
).toBe(diffOptionsHoverBackground);
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -675,6 +710,12 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
|||||||
|
|
||||||
await scrollToLowerUnwrappedDiffRows(page);
|
await scrollToLowerUnwrappedDiffRows(page);
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
await page.getByTestId("changes-toggle-tree").click();
|
||||||
|
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId("changes-toggle-tree")
|
||||||
|
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||||
|
).toBe(diffOptionsHoverBackground);
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
const content = changesContent(page);
|
const content = changesContent(page);
|
||||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
@@ -735,7 +776,38 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
|||||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
await page.getByTestId("changes-toggle-tree").click();
|
||||||
|
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
||||||
await expectFlatFileList(page);
|
await expectFlatFileList(page);
|
||||||
|
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
||||||
|
await page.screenshot({ path: screenshot });
|
||||||
|
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
await page.setViewportSize({ width: 480, height: 900 });
|
||||||
|
|
||||||
|
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||||
|
const [optionsBox, glyphBox] = await Promise.all([
|
||||||
|
options.boundingBox(),
|
||||||
|
options.locator("svg").boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!optionsBox || !glyphBox) {
|
||||||
|
throw new Error("Compact Changes options geometry could not be measured");
|
||||||
|
}
|
||||||
|
expect(optionsBox.width).toBe(32);
|
||||||
|
expect(optionsBox.height).toBe(32);
|
||||||
|
expect(glyphBox.width).toBe(18);
|
||||||
|
expect(glyphBox.height).toBe(18);
|
||||||
|
|
||||||
|
await options.click();
|
||||||
|
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||||
|
await expect(wrapLines).toBeVisible();
|
||||||
|
await wrapLines.click();
|
||||||
|
await options.click();
|
||||||
|
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||||
@@ -875,6 +947,7 @@ test("split canvas creates a review on the changed side and keeps it in that col
|
|||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||||
await startReviewOnFirstChangedLine(page, "right");
|
await startReviewOnFirstChangedLine(page, "right");
|
||||||
const [editor, body] = await Promise.all([
|
const [editor, body] = await Promise.all([
|
||||||
page.getByTestId("inline-review-editor").boundingBox(),
|
page.getByTestId("inline-review-editor").boundingBox(),
|
||||||
@@ -1004,6 +1077,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
|
|||||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
|
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||||
const before = await readSelectionPaintSamples(page, "right");
|
const before = await readSelectionPaintSamples(page, "right");
|
||||||
await dragAddedTextRange(page, {
|
await dragAddedTextRange(page, {
|
||||||
lines,
|
lines,
|
||||||
@@ -1027,6 +1101,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
|
|||||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
|
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
|
||||||
await dragAddedTextRange(page, {
|
await dragAddedTextRange(page, {
|
||||||
lines: [content],
|
lines: [content],
|
||||||
wrapped: true,
|
wrapped: true,
|
||||||
@@ -1151,6 +1226,31 @@ async function configureDiffPresentation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setOpenChangesPresentation(
|
||||||
|
page: Page,
|
||||||
|
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||||
|
|
||||||
|
await options.click();
|
||||||
|
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||||
|
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
||||||
|
if (currentLayout !== requestedPresentation.layout) {
|
||||||
|
await layoutItem.click();
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
await options.click();
|
||||||
|
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
||||||
|
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
||||||
|
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||||
|
await wrapItem.click();
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const fontSet = document.fonts;
|
const fontSet = document.fonts;
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||||
|
const [commitToolbarBox, layoutToggleBox, layoutToggleGlyphBox] = await Promise.all([
|
||||||
|
panel.getByTestId("commit-diff-header").boundingBox(),
|
||||||
|
layoutToggle.boundingBox(),
|
||||||
|
layoutToggle.locator("svg").boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!commitToolbarBox || !layoutToggleBox || !layoutToggleGlyphBox) {
|
||||||
|
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||||
|
}
|
||||||
|
expect(commitToolbarBox.height).toBe(36);
|
||||||
|
expect(layoutToggleBox.width).toBe(24);
|
||||||
|
expect(layoutToggleBox.height).toBe(24);
|
||||||
|
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||||
|
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
await expectCommitDiffHeaderGeometry(panel);
|
await expectCommitDiffHeaderGeometry(panel);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
await openChangesPanel(page);
|
||||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expectSingleCanvasSurface(page);
|
await expectSingleCanvasSurface(page);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
|||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
function visible(page: Page, testId: string): Locator {
|
function visible(page: Page, testId: string): Locator {
|
||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
@@ -37,9 +37,8 @@ function mainTabRow(page: Page): Locator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function focusExplorerPane(page: Page): Promise<void> {
|
async function focusExplorerPane(page: Page): Promise<void> {
|
||||||
await ensureExplorerPane(page);
|
await openChangesPanel(page);
|
||||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
||||||
await changesTab.click({ position: { x: 12, y: 13 } });
|
|
||||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +53,15 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
|||||||
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emptyPaneBox(page: Page) {
|
function emptyPaneChild(page: Page): Locator {
|
||||||
const paneChild = page
|
return page
|
||||||
.getByTestId("split-group-child")
|
.getByTestId("split-group-child")
|
||||||
.filter({ hasText: "No tabs in this pane." })
|
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
|
||||||
.first();
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyPaneBox(page: Page) {
|
||||||
|
const paneChild = emptyPaneChild(page);
|
||||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||||
const box = await paneChild.boundingBox();
|
const box = await paneChild.boundingBox();
|
||||||
expect(box).not.toBeNull();
|
expect(box).not.toBeNull();
|
||||||
@@ -170,7 +173,7 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
});
|
});
|
||||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
||||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||||
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
|
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("app must stay interactive after the split", async () => {
|
await test.step("app must stay interactive after the split", async () => {
|
||||||
@@ -183,12 +186,10 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||||
|
|
||||||
// Clicking inside the empty pane (focuses it) must not lock anything.
|
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
|
||||||
const emptyPane = page
|
// above the vertically centred launcher, which would open a tab instead.
|
||||||
.getByTestId("split-group-child")
|
const emptyPane = emptyPaneChild(page);
|
||||||
.filter({ hasText: "No tabs in this pane." })
|
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||||
.first();
|
|
||||||
await emptyPane.click({ timeout: 5_000 });
|
|
||||||
|
|
||||||
// The empty pane's + menu must open and dismiss cleanly.
|
// The empty pane's + menu must open and dismiss cleanly.
|
||||||
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
||||||
@@ -207,7 +208,9 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
// The agent chip must have moved into the previously empty pane and stay clickable.
|
||||||
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
|
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
await agentTabChip(page, agentId)
|
await agentTabChip(page, agentId)
|
||||||
.first()
|
.first()
|
||||||
.click({ position: { x: 12, y: 13 } });
|
.click({ position: { x: 12, y: 13 } });
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ function dockedSidebar(page: Page): Locator {
|
|||||||
return visible(page, "explorer-header");
|
return visible(page, "explorer-header");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The explorer pane is the tab row carrying the Changes tab. */
|
/** The desktop companion surface is a hideable side panel. */
|
||||||
function explorerTabRow(page: Page): Locator {
|
function sidePanel(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "workspace-side-panel");
|
||||||
has: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||||
@@ -72,11 +70,12 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
|
|
||||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||||
|
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,20 +107,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
|
|
||||||
await test.step("open the explorer so both sides are showing", async () => {
|
await test.step("open the explorer so both sides are showing", async () => {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||||
await expect(agentList).toHaveCount(1);
|
await expect(agentList).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut collapses both", async () => {
|
await test.step("the shortcut collapses both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(agentList).toHaveCount(0);
|
await expect(agentList).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut restores both", async () => {
|
await test.step("the shortcut restores both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
|
||||||
|
import {
|
||||||
|
expandFolder,
|
||||||
|
openFileExplorer,
|
||||||
|
openFileFromExplorer,
|
||||||
|
} from "../support/helpers/file-explorer";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "file-transition-",
|
||||||
|
repo: {
|
||||||
|
files: [
|
||||||
|
{ path: "src/a.ts", content: "export const sourceA = true;\n" },
|
||||||
|
{ path: "src/b.ts", content: "export const sourceB = true;\n" },
|
||||||
|
{ path: "docs/b.md", content: "# Markdown B\n" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => workspace?.cleanup());
|
||||||
|
|
||||||
|
async function openSourceA(page: import("@playwright/test").Page) {
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await expandFolder(page, "src");
|
||||||
|
await openFileFromExplorer(page, "a.ts");
|
||||||
|
await expect(page.getByText("export const sourceA = true;")).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHeldFileTransition(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
targetPath: string,
|
||||||
|
finalText: string,
|
||||||
|
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||||
|
) {
|
||||||
|
await page.evaluate((tabTestId) => {
|
||||||
|
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||||
|
const sample = () => {
|
||||||
|
const next = {
|
||||||
|
chip: Boolean(document.querySelector(`[data-testid="${tabTestId}"]`)),
|
||||||
|
loading: Boolean(document.querySelector('[data-testid="file-preview-loading"]')),
|
||||||
|
unavailable: Boolean(document.querySelector('[data-testid="file-preview-unsupported"]')),
|
||||||
|
};
|
||||||
|
if (JSON.stringify(timeline.at(-1)) !== JSON.stringify(next)) timeline.push(next);
|
||||||
|
};
|
||||||
|
sample();
|
||||||
|
const observer = new MutationObserver(sample);
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionTimeline?: typeof timeline }
|
||||||
|
).__fileTransitionTimeline = timeline;
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionObserver?: MutationObserver }
|
||||||
|
).__fileTransitionObserver = observer;
|
||||||
|
}, `workspace-tab-file_${targetPath}`);
|
||||||
|
await openFileFromExplorer(page, targetPath.split("/").pop() as string);
|
||||||
|
await gate.waitUntilHeld();
|
||||||
|
|
||||||
|
const targetChip = page.getByTestId(`workspace-tab-file_${targetPath}`).first();
|
||||||
|
await expect(targetChip).toBeVisible();
|
||||||
|
await expect(targetChip).toContainText(targetPath.split("/").pop() as string);
|
||||||
|
await expect(page.getByTestId("file-preview-loading")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||||
|
|
||||||
|
const heldTimeline = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as typeof window & {
|
||||||
|
__fileTransitionTimeline?: Array<{
|
||||||
|
chip: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
unavailable: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).__fileTransitionTimeline ?? [],
|
||||||
|
);
|
||||||
|
expect(heldTimeline).toContainEqual(expect.objectContaining({ chip: true, loading: true }));
|
||||||
|
expect(heldTimeline).not.toContainEqual(expect.objectContaining({ unavailable: true }));
|
||||||
|
const descriptorIndex = heldTimeline.findIndex((sample) => sample.chip);
|
||||||
|
expect(heldTimeline.slice(descriptorIndex)).not.toContainEqual(
|
||||||
|
expect.objectContaining({ chip: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
gate.release();
|
||||||
|
await expect(page.getByText(finalText)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("file-preview-loading")).toHaveCount(0);
|
||||||
|
const timeline = await page.evaluate(() => {
|
||||||
|
const windowWithTimeline = window as typeof window & {
|
||||||
|
__fileTransitionTimeline?: Array<{ chip: boolean; loading: boolean; unavailable: boolean }>;
|
||||||
|
__fileTransitionObserver?: MutationObserver;
|
||||||
|
};
|
||||||
|
windowWithTimeline.__fileTransitionObserver?.disconnect();
|
||||||
|
return windowWithTimeline.__fileTransitionTimeline ?? [];
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
timeline.filter((sample, index) => sample.loading && !timeline[index - 1]?.loading),
|
||||||
|
).toHaveLength(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("File target transitions", () => {
|
||||||
|
test("keeps the Markdown descriptor and loading state stable through a real delayed read", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const gate = await delayFileReadResponse(page, "docs/b.md");
|
||||||
|
await page.reload();
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openSourceA(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await assertHeldFileTransition(page, "docs/b.md", "Markdown B", gate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the source descriptor and loading state stable through a real delayed read", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const gate = await delayFileReadResponse(page, "src/b.ts");
|
||||||
|
await page.reload();
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openSourceA(page);
|
||||||
|
await assertHeldFileTransition(page, "src/b.ts", "export const sourceB = true;", gate);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
||||||
|
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
||||||
|
// was permanent on desktop.
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "file-tree-rail-toggle-",
|
||||||
|
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await workspace?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("File panel tree rail", () => {
|
||||||
|
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await openFileFromExplorer(page, "docs");
|
||||||
|
await openFileFromExplorer(page, "guide.md");
|
||||||
|
|
||||||
|
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
||||||
|
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
||||||
|
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||||
|
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
||||||
|
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
||||||
|
const hiddenFiles = page
|
||||||
|
.getByRole("button", { name: "Hide hidden files" })
|
||||||
|
.filter({ visible: true });
|
||||||
|
const shoot = async (name: string) => {
|
||||||
|
const path = testInfo.outputPath(`${name}.png`);
|
||||||
|
await page.screenshot({ path });
|
||||||
|
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||||
|
};
|
||||||
|
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||||
|
const [
|
||||||
|
editorToolbarBox,
|
||||||
|
filesToolbarBox,
|
||||||
|
treeToggleBox,
|
||||||
|
refreshBox,
|
||||||
|
treeGlyphBox,
|
||||||
|
refreshGlyphBox,
|
||||||
|
] = await Promise.all([
|
||||||
|
editorToolbar.boundingBox(),
|
||||||
|
filesToolbar.boundingBox(),
|
||||||
|
toggle.boundingBox(),
|
||||||
|
refresh.boundingBox(),
|
||||||
|
toggle.locator("svg").boundingBox(),
|
||||||
|
refresh.locator("svg").boundingBox(),
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
!editorToolbarBox ||
|
||||||
|
!filesToolbarBox ||
|
||||||
|
!treeToggleBox ||
|
||||||
|
!refreshBox ||
|
||||||
|
!treeGlyphBox ||
|
||||||
|
!refreshGlyphBox
|
||||||
|
) {
|
||||||
|
throw new Error("Pane-content toolbar geometry could not be measured");
|
||||||
|
}
|
||||||
|
expect(editorToolbarBox.height).toBe(36);
|
||||||
|
expect(filesToolbarBox.height).toBe(36);
|
||||||
|
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
||||||
|
filesToolbarBox.y + filesToolbarBox.height,
|
||||||
|
);
|
||||||
|
expect(treeToggleBox.width).toBe(24);
|
||||||
|
expect(treeToggleBox.height).toBe(24);
|
||||||
|
expect(refreshBox.width).toBe(24);
|
||||||
|
expect(refreshBox.height).toBe(24);
|
||||||
|
expect(treeGlyphBox.width).toBe(14);
|
||||||
|
expect(treeGlyphBox.height).toBe(14);
|
||||||
|
expect(refreshGlyphBox.width).toBe(14);
|
||||||
|
expect(refreshGlyphBox.height).toBe(14);
|
||||||
|
|
||||||
|
const restBackground = await refresh.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
const treeToggleSelectedBackground = await toggle.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
await refresh.hover();
|
||||||
|
const refreshHoverBackground = await refresh.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
expect(refreshHoverBackground).not.toBe(restBackground);
|
||||||
|
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
||||||
|
await hiddenFiles.click();
|
||||||
|
const hiddenFilesSelected = page
|
||||||
|
.getByRole("button", { name: "Show hidden files" })
|
||||||
|
.filter({ visible: true });
|
||||||
|
expect(
|
||||||
|
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||||
|
).toBe(refreshHoverBackground);
|
||||||
|
await shoot("tree-open");
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
||||||
|
await expect(rail).toHaveCount(0);
|
||||||
|
await shoot("tree-closed");
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(rail).toBeVisible();
|
||||||
|
await shoot("tree-reopened");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||||
|
const path = testInfo.outputPath(`${name}.png`);
|
||||||
|
await page.screenshot({ path });
|
||||||
|
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidePanel(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainPane(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(scope: Locator): Locator {
|
||||||
|
return scope.getByTestId("workspace-pane-empty-state");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidePanelToggle(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-explorer-toggle").first();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePaneButton(page: Page): Locator {
|
||||||
|
return page.getByRole("button", { name: "Close pane" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
||||||
|
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
||||||
|
const paneBox = await mainPane(page).boundingBox();
|
||||||
|
const chipBox = await chip.boundingBox();
|
||||||
|
expect(paneBox).not.toBeNull();
|
||||||
|
expect(chipBox).not.toBeNull();
|
||||||
|
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
||||||
|
|
||||||
|
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
||||||
|
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
||||||
|
steps: 20,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Side panel", () => {
|
||||||
|
test("reveals empty, launches a view, and hides back out of the way", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(sidePanel(page)).toHaveCount(0);
|
||||||
|
|
||||||
|
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||||
|
await sidePanelToggle(page).click();
|
||||||
|
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(emptyState(sidePanel(page))).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
|
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Changes opens in the Side panel that offered it", async () => {
|
||||||
|
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a loaded pane offers no one-tap close", async () => {
|
||||||
|
await expect(closePaneButton(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
||||||
|
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
||||||
|
await changesTab.hover();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
||||||
|
|
||||||
|
await sidePanelToggle(page).click();
|
||||||
|
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(emptyState(sidePanel(page))).toBeVisible();
|
||||||
|
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await ensureSidePanel(page);
|
||||||
|
|
||||||
|
// Dragging the pane's only tab into its own left edge splits that tab into a
|
||||||
|
// new pane and leaves the one it came from empty.
|
||||||
|
const draftTab = page
|
||||||
|
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
||||||
|
await splitTabOutOfMainPane(page, draftTab);
|
||||||
|
const draftPane = page
|
||||||
|
.locator('[data-testid^="workspace-pane-pane_"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const emptyMain = emptyState(mainPane(page));
|
||||||
|
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
||||||
|
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
||||||
|
|
||||||
|
await emptyMain.getByRole("button", { name: "Close pane" }).click();
|
||||||
|
|
||||||
|
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
||||||
|
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect(sidePanel(page)).toBeVisible();
|
||||||
|
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
|
||||||
|
await capture(page, testInfo, "07-empty-main-pane-removed");
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
// Park a tab in the Side panel, then put the panel away. The workspace still
|
||||||
|
// holds that tab, so emptying Main does not look like an empty workspace and
|
||||||
|
// nothing reseeds a draft into it.
|
||||||
|
await ensureSidePanel(page);
|
||||||
|
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await sidePanelToggle(page).click();
|
||||||
|
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const draftTab = page
|
||||||
|
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await draftTab.hover();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="workspace-draft-close-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Main is now empty and the only pane the user can see. Taking it away would
|
||||||
|
// leave nowhere to look, so the launcher appears without a Close pane.
|
||||||
|
const lastPane = emptyState(mainPane(page));
|
||||||
|
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
|
||||||
|
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
|
||||||
|
await expect(closePaneButton(page)).toHaveCount(0);
|
||||||
|
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await ensureSidePanel(page);
|
||||||
|
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await capture(page, testInfo, "08-changes-in-side-panel");
|
||||||
|
|
||||||
|
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-new-tab-menu-changes")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||||
|
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
||||||
|
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -343,7 +343,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
await openFilesPanel(page);
|
await openFilesPanel(page);
|
||||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
|
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||||
|
|
||||||
@@ -351,7 +351,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
.first()
|
.first()
|
||||||
.elementHandle();
|
.elementHandle();
|
||||||
expect(originalComposer).not.toBeNull();
|
expect(originalComposer).not.toBeNull();
|
||||||
|
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
|
||||||
|
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
|
||||||
2,
|
emptyPanesBeforeSplit + 1,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
||||||
|
|
||||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||||
expect(originalStillConnected).toBe(true);
|
expect(originalStillConnected).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
function fileReadRequest(message: WebSocketMessage, path: string): boolean {
|
||||||
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||||
|
const session =
|
||||||
|
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||||
|
? (envelope.message as { type?: unknown; mode?: unknown; path?: unknown })
|
||||||
|
: (envelope as { type?: unknown; mode?: unknown; path?: unknown });
|
||||||
|
return (
|
||||||
|
session.type === "file_explorer_request" && session.mode === "file" && session.path === path
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delayFileReadResponse(page: Page, path: string) {
|
||||||
|
let delayed: (() => void) | null = null;
|
||||||
|
let resolveDelayed: (() => void) | null = null;
|
||||||
|
const delayedResponse = new Promise<void>((resolve) => {
|
||||||
|
resolveDelayed = resolve;
|
||||||
|
});
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
server.onMessage((message) => {
|
||||||
|
ws.send(message);
|
||||||
|
});
|
||||||
|
// Hold the outgoing JSON request. The corresponding body response travels as
|
||||||
|
// binary frames, so intercepting the response cannot identify this read.
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
if (delayed || !fileReadRequest(message, path)) {
|
||||||
|
server.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delayed = () => server.send(message);
|
||||||
|
resolveDelayed?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
waitUntilHeld: () => delayedResponse,
|
||||||
|
release: () => delayed?.(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ function splitSeparator(page: Page): Locator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
export async function openExplorerSplit(page: Page): Promise<void> {
|
||||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
await page.getByRole("button", { name: "Open side panel" }).click();
|
||||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
export async function hideExplorerSplit(page: Page): Promise<void> {
|
||||||
await page.getByRole("button", { name: "Close explorer" }).click();
|
await page.getByRole("button", { name: "Close side panel" }).click();
|
||||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ function visibleTestId(page: Page, testId: string) {
|
|||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function explorerPaneTabRow(page: Page) {
|
function sidePanel(page: Page) {
|
||||||
return visibleTestId(page, "workspace-tabs-row")
|
return visibleTestId(page, "workspace-side-panel").first();
|
||||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
|
||||||
.first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||||
@@ -32,35 +30,51 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
|||||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureExplorerPane(page: Page) {
|
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
|
||||||
|
export async function ensureSidePanel(page: Page): Promise<Locator> {
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||||
const tabRow = explorerPaneTabRow(page);
|
const panel = sidePanel(page);
|
||||||
if ((await tabRow.count()) === 0) {
|
if ((await panel.count()) === 0) {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
}
|
}
|
||||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
return tabRow;
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveals the Side panel and brings one of its views up in it. Goes through the
|
||||||
|
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
|
||||||
|
*/
|
||||||
|
async function openSidePanelView(
|
||||||
|
page: Page,
|
||||||
|
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const panel = await ensureSidePanel(page);
|
||||||
|
const tab = panel.getByTestId(view.tabTestId);
|
||||||
|
if ((await tab.count()) === 0) {
|
||||||
|
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||||
|
await visibleTestId(page, view.menuTestId).first().click();
|
||||||
|
}
|
||||||
|
await selectWorkspaceTab(tab);
|
||||||
|
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||||
|
timeout: view.timeout ?? 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
const tabRow = await ensureExplorerPane(page);
|
await openSidePanelView(page, {
|
||||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
tabTestId: "workspace-tab-working_diff",
|
||||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
menuTestId: "workspace-new-tab-menu-changes",
|
||||||
timeout: 30_000,
|
contentTestId: "working-diff-panel",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openFilesPanel(page: Page): Promise<void> {
|
export async function openFilesPanel(page: Page): Promise<void> {
|
||||||
const tabRow = await ensureExplorerPane(page);
|
await openSidePanelView(page, {
|
||||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
tabTestId: "workspace-tab-files",
|
||||||
if ((await filesTab.count()) === 0) {
|
menuTestId: "workspace-new-tab-menu-files",
|
||||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
contentTestId: "file-explorer-tree-scroll",
|
||||||
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
|
|
||||||
}
|
|
||||||
await selectWorkspaceTab(filesTab);
|
|
||||||
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +85,12 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
|||||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabRow = await ensureExplorerPane(page);
|
await openSidePanelView(page, {
|
||||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
tabTestId: "workspace-tab-pull_request",
|
||||||
if ((await pullRequestTab.count()) === 0) {
|
menuTestId: "workspace-new-tab-menu-pull-request",
|
||||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
contentTestId: "pr-pane",
|
||||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
timeout: 15_000,
|
||||||
}
|
});
|
||||||
await selectWorkspaceTab(pullRequestTab);
|
|
||||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
|
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
SpeakMessage,
|
SpeakMessage,
|
||||||
@@ -450,7 +450,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
openExplorerSurface({
|
openSidePanelView({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
|
|||||||
@@ -132,7 +132,13 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
|||||||
filePath: path,
|
filePath: path,
|
||||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||||
openWorkspaceTabInFocusedPane: useWorkspaceLayoutStore.getState().openTabInFocusedPane,
|
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||||
|
useWorkspaceLayoutStore.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target,
|
||||||
|
intent: "reveal",
|
||||||
|
placement,
|
||||||
|
}),
|
||||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -99,8 +100,11 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
|||||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
|
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||||
(state) => state.openTabInFocusedPane,
|
const openWorkspaceTabInFocusedPane = useCallback(
|
||||||
|
(workspaceKey: string, target: WorkspaceTabTarget) =>
|
||||||
|
openTab({ workspaceKey, target, intent: "reveal" }),
|
||||||
|
[openTab],
|
||||||
);
|
);
|
||||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { ChangesSurface } from "@/git/diff-pane";
|
import { ChangesSurface } from "@/git/diff-pane";
|
||||||
|
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
|
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
|
||||||
@@ -229,7 +230,7 @@ function ExplorerSidebarContent({
|
|||||||
<ExplorerTabButton
|
<ExplorerTabButton
|
||||||
tab="changes"
|
tab="changes"
|
||||||
active={resolvedTab === "changes"}
|
active={resolvedTab === "changes"}
|
||||||
label={t("workspace.tabs.explorer.changes")}
|
label={t("workspace.tabs.sidePanel.changes")}
|
||||||
onTabPress={onTabPress}
|
onTabPress={onTabPress}
|
||||||
testID="explorer-tab-changes"
|
testID="explorer-tab-changes"
|
||||||
/>
|
/>
|
||||||
@@ -237,7 +238,7 @@ function ExplorerSidebarContent({
|
|||||||
<ExplorerTabButton
|
<ExplorerTabButton
|
||||||
tab="files"
|
tab="files"
|
||||||
active={resolvedTab === "files"}
|
active={resolvedTab === "files"}
|
||||||
label={t("workspace.tabs.explorer.files")}
|
label={t("workspace.tabs.sidePanel.files")}
|
||||||
onTabPress={onTabPress}
|
onTabPress={onTabPress}
|
||||||
testID="explorer-tab-files"
|
testID="explorer-tab-files"
|
||||||
/>
|
/>
|
||||||
@@ -267,7 +268,7 @@ function ExplorerSidebarContent({
|
|||||||
nativeID="explorer-close"
|
nativeID="explorer-close"
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("workspace.tabs.explorer.close")}
|
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) => (
|
{({ hovered, pressed }) => (
|
||||||
@@ -329,6 +330,9 @@ function ChangedFilesPane({
|
|||||||
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||||
>) {
|
>) {
|
||||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
const [changesState, setChangesState] = useState<ChangesState>(() =>
|
||||||
|
changesStateSchema.parse(defaultChangesState),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<ChangesSurface
|
<ChangesSurface
|
||||||
host="explorer"
|
host="explorer"
|
||||||
@@ -338,6 +342,8 @@ function ChangedFilesPane({
|
|||||||
enabled={isOpen}
|
enabled={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
onAddToChat={canAddToChat ? addFile : undefined}
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
state={changesState}
|
||||||
|
onStateChange={setChangesState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
|
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
|
||||||
@@ -38,6 +38,11 @@ import {
|
|||||||
WORKSPACE_TREE_LOADING_ICON_SIZE,
|
WORKSPACE_TREE_LOADING_ICON_SIZE,
|
||||||
} from "@/components/tree-primitives";
|
} from "@/components/tree-primitives";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import {
|
||||||
|
PaneContentToolbar,
|
||||||
|
paneContentToolbarIconSize,
|
||||||
|
paneContentToolbarIconButtonStyle,
|
||||||
|
} from "@/components/ui/pane-content-toolbar";
|
||||||
import {
|
import {
|
||||||
useOverlayFlatListScrollbar,
|
useOverlayFlatListScrollbar,
|
||||||
type OverlayFlatListScrollbar,
|
type OverlayFlatListScrollbar,
|
||||||
@@ -136,8 +141,11 @@ function sortTriggerStyle({
|
|||||||
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
|
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
function iconButtonStyle(
|
||||||
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
|
isCompact: boolean,
|
||||||
|
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||||
|
) {
|
||||||
|
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExplorerListRow =
|
type ExplorerListRow =
|
||||||
@@ -1041,6 +1049,11 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [requestDirectoryListing]);
|
}, [requestDirectoryListing]);
|
||||||
|
|
||||||
|
const toolbarIconButtonStyle = useCallback(
|
||||||
|
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
|
||||||
|
[isCompact],
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -1058,6 +1071,7 @@ export function FileExplorerPane({
|
|||||||
>
|
>
|
||||||
<FileExplorerPaneContent
|
<FileExplorerPaneContent
|
||||||
error={error}
|
error={error}
|
||||||
|
isCompact={isCompact}
|
||||||
showInitialLoading={showInitialLoading}
|
showInitialLoading={showInitialLoading}
|
||||||
showBackFromError={showBackFromError}
|
showBackFromError={showBackFromError}
|
||||||
listRows={listRows}
|
listRows={listRows}
|
||||||
@@ -1073,7 +1087,7 @@ export function FileExplorerPane({
|
|||||||
handleBackFromError={handleBackFromError}
|
handleBackFromError={handleBackFromError}
|
||||||
handleRetry={handleRetry}
|
handleRetry={handleRetry}
|
||||||
sortTriggerStyle={sortTriggerStyle}
|
sortTriggerStyle={sortTriggerStyle}
|
||||||
iconButtonStyle={iconButtonStyle}
|
iconButtonStyle={toolbarIconButtonStyle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -1132,6 +1146,7 @@ function RootCreationContextTarget({
|
|||||||
|
|
||||||
interface FileExplorerPaneContentProps {
|
interface FileExplorerPaneContentProps {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
isCompact: boolean;
|
||||||
showInitialLoading: boolean;
|
showInitialLoading: boolean;
|
||||||
showBackFromError: boolean;
|
showBackFromError: boolean;
|
||||||
listRows: ExplorerListRow[];
|
listRows: ExplorerListRow[];
|
||||||
@@ -1155,6 +1170,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
|
isCompact,
|
||||||
showInitialLoading,
|
showInitialLoading,
|
||||||
showBackFromError,
|
showBackFromError,
|
||||||
listRows,
|
listRows,
|
||||||
@@ -1229,7 +1245,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
<View style={styles.paneHeader} testID="files-pane-header">
|
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleSortCycle}
|
onPress={handleSortCycle}
|
||||||
style={sortTriggerStyleProp}
|
style={sortTriggerStyleProp}
|
||||||
@@ -1251,7 +1267,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
accessibilityLabel={t("workspace.fileActions.newFile")}
|
accessibilityLabel={t("workspace.fileActions.newFile")}
|
||||||
testID="files-new-file"
|
testID="files-new-file"
|
||||||
>
|
>
|
||||||
<FilePlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<FilePlus
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleNewFolderAtRoot}
|
onPress={handleNewFolderAtRoot}
|
||||||
@@ -1261,7 +1280,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
accessibilityLabel={t("workspace.fileActions.newFolder")}
|
accessibilityLabel={t("workspace.fileActions.newFolder")}
|
||||||
testID="files-new-folder"
|
testID="files-new-folder"
|
||||||
>
|
>
|
||||||
<FolderPlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<FolderPlus
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1275,9 +1297,15 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
testID="files-hidden-toggle"
|
testID="files-hidden-toggle"
|
||||||
>
|
>
|
||||||
{showHiddenFiles ? (
|
{showHiddenFiles ? (
|
||||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Eye
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<EyeOff
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -1295,14 +1323,20 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
>
|
>
|
||||||
<View style={styles.refreshIcon}>
|
<View style={styles.refreshIcon}>
|
||||||
{isRefreshFetching ? (
|
{isRefreshFetching ? (
|
||||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<LoadingSpinner
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<RotateCw
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</PaneContentToolbar>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
|
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
|
||||||
{listRows.length === 0 ? (
|
{listRows.length === 0 ? (
|
||||||
@@ -1662,13 +1696,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
paneHeader: {
|
paneHeader: {
|
||||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingRight: theme.spacing[3],
|
paddingRight: theme.spacing[3],
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
},
|
||||||
sortTrigger: {
|
sortTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1806,16 +1837,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
iconButton: {
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
iconButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
iconButtonActive: {
|
iconButtonActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,9 +129,14 @@ interface SplitContainerProps {
|
|||||||
position: "left" | "right" | "top" | "bottom";
|
position: "left" | "right" | "top" | "bottom";
|
||||||
}) => void;
|
}) => void;
|
||||||
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
|
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
|
||||||
|
onClosePane: (paneId: string) => void;
|
||||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||||
renderPaneEmptyState?: () => ReactNode;
|
renderPaneEmptyState?: (input: {
|
||||||
|
paneId: string;
|
||||||
|
paneIsolated: boolean;
|
||||||
|
onClosePane: () => void;
|
||||||
|
}) => ReactNode;
|
||||||
focusModeEnabled?: boolean;
|
focusModeEnabled?: boolean;
|
||||||
onExitFocusMode: () => void;
|
onExitFocusMode: () => void;
|
||||||
}
|
}
|
||||||
@@ -174,9 +179,10 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
|
|||||||
node: SplitNode;
|
node: SplitNode;
|
||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
focusedPaneId: string | null;
|
focusedPaneId: string | null;
|
||||||
explorerPaneId: string | null;
|
sidePanelPaneId: string | null;
|
||||||
maximizedPaneId: string | null;
|
maximizedPaneId: string | null;
|
||||||
onTogglePaneMaximized: (paneId: string) => void;
|
onTogglePaneMaximized: (paneId: string) => void;
|
||||||
|
paneIsolated: boolean;
|
||||||
activeDragTabId: string | null;
|
activeDragTabId: string | null;
|
||||||
showDropZones: boolean;
|
showDropZones: boolean;
|
||||||
dropPreview: SplitDropZoneHover | null;
|
dropPreview: SplitDropZoneHover | null;
|
||||||
@@ -263,6 +269,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
|
|||||||
cachedDescriptor &&
|
cachedDescriptor &&
|
||||||
cachedDescriptor.key === tabDescriptor.key &&
|
cachedDescriptor.key === tabDescriptor.key &&
|
||||||
cachedDescriptor.kind === tabDescriptor.kind &&
|
cachedDescriptor.kind === tabDescriptor.kind &&
|
||||||
|
cachedDescriptor.state === tabDescriptor.state &&
|
||||||
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
|
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
|
||||||
) {
|
) {
|
||||||
next.set(tabDescriptor.tabId, cachedDescriptor);
|
next.set(tabDescriptor.tabId, cachedDescriptor);
|
||||||
@@ -404,6 +411,7 @@ export function SplitContainer({
|
|||||||
onSplitPane,
|
onSplitPane,
|
||||||
onSplitPaneEmpty,
|
onSplitPaneEmpty,
|
||||||
onMoveTabToPane,
|
onMoveTabToPane,
|
||||||
|
onClosePane,
|
||||||
onResizeSplit,
|
onResizeSplit,
|
||||||
onReorderTabsInPane,
|
onReorderTabsInPane,
|
||||||
renderPaneEmptyState = () => null,
|
renderPaneEmptyState = () => null,
|
||||||
@@ -421,8 +429,8 @@ export function SplitContainer({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const maximizedPaneId =
|
const maximizedPaneId =
|
||||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||||
const explorerPaneId = useWorkspaceLayoutStore(
|
const sidePanelPaneId = useWorkspaceLayoutStore(
|
||||||
(state) => state.explorerPaneIdByWorkspace[workspaceKey] ?? null,
|
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -451,7 +459,7 @@ export function SplitContainer({
|
|||||||
if (action.id !== "workspace.explorer.maximize.toggle") {
|
if (action.id !== "workspace.explorer.maximize.toggle") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
|
||||||
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
|
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -459,7 +467,7 @@ export function SplitContainer({
|
|||||||
handleTogglePaneMaximized(explorerPane.id);
|
handleTogglePaneMaximized(explorerPane.id);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[explorerPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
|
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
@@ -494,6 +502,11 @@ export function SplitContainer({
|
|||||||
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
|
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
|
||||||
);
|
);
|
||||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||||
|
// Isolating a pane (focus mode, maximize) hides its siblings without removing
|
||||||
|
// them, so the one pane on screen is not really the last one and closing it is not
|
||||||
|
// what anyone means. That is all this container knows; whether the layout itself
|
||||||
|
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
|
||||||
|
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||||
@@ -654,7 +667,7 @@ export function SplitContainer({
|
|||||||
workspaceKey={workspaceKey}
|
workspaceKey={workspaceKey}
|
||||||
uiTabs={uiTabs}
|
uiTabs={uiTabs}
|
||||||
focusedPaneId={layout.focusedPaneId}
|
focusedPaneId={layout.focusedPaneId}
|
||||||
explorerPaneId={explorerPaneId}
|
sidePanelPaneId={sidePanelPaneId}
|
||||||
maximizedPaneId={maximizedPaneId}
|
maximizedPaneId={maximizedPaneId}
|
||||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
@@ -682,6 +695,8 @@ export function SplitContainer({
|
|||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||||
|
paneIsolated={paneIsolated}
|
||||||
|
onClosePane={onClosePane}
|
||||||
onResizeSplit={onResizeSplit}
|
onResizeSplit={onResizeSplit}
|
||||||
onReorderTabsInPane={onReorderTabsInPane}
|
onReorderTabsInPane={onReorderTabsInPane}
|
||||||
renderPaneEmptyState={renderPaneEmptyState}
|
renderPaneEmptyState={renderPaneEmptyState}
|
||||||
@@ -863,7 +878,7 @@ function SplitNodeView({
|
|||||||
workspaceKey,
|
workspaceKey,
|
||||||
uiTabs,
|
uiTabs,
|
||||||
focusedPaneId,
|
focusedPaneId,
|
||||||
explorerPaneId,
|
sidePanelPaneId,
|
||||||
maximizedPaneId,
|
maximizedPaneId,
|
||||||
onTogglePaneMaximized,
|
onTogglePaneMaximized,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
@@ -891,6 +906,8 @@ function SplitNodeView({
|
|||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
onSplitPaneEmpty,
|
onSplitPaneEmpty,
|
||||||
|
paneIsolated,
|
||||||
|
onClosePane,
|
||||||
onResizeSplit,
|
onResizeSplit,
|
||||||
onReorderTabsInPane,
|
onReorderTabsInPane,
|
||||||
renderPaneEmptyState,
|
renderPaneEmptyState,
|
||||||
@@ -942,7 +959,7 @@ function SplitNodeView({
|
|||||||
pane={node.pane}
|
pane={node.pane}
|
||||||
uiTabs={uiTabs}
|
uiTabs={uiTabs}
|
||||||
isFocused={node.pane.id === focusedPaneId}
|
isFocused={node.pane.id === focusedPaneId}
|
||||||
explorerPaneId={explorerPaneId}
|
sidePanelPaneId={sidePanelPaneId}
|
||||||
maximizedPaneId={maximizedPaneId}
|
maximizedPaneId={maximizedPaneId}
|
||||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
@@ -970,6 +987,8 @@ function SplitNodeView({
|
|||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||||
|
paneIsolated={paneIsolated}
|
||||||
|
onClosePane={onClosePane}
|
||||||
onReorderTabsInPane={onReorderTabsInPane}
|
onReorderTabsInPane={onReorderTabsInPane}
|
||||||
renderPaneEmptyState={renderPaneEmptyState}
|
renderPaneEmptyState={renderPaneEmptyState}
|
||||||
activeDragTabId={activeDragTabId}
|
activeDragTabId={activeDragTabId}
|
||||||
@@ -994,7 +1013,7 @@ function SplitNodeView({
|
|||||||
workspaceKey={workspaceKey}
|
workspaceKey={workspaceKey}
|
||||||
uiTabs={uiTabs}
|
uiTabs={uiTabs}
|
||||||
focusedPaneId={focusedPaneId}
|
focusedPaneId={focusedPaneId}
|
||||||
explorerPaneId={explorerPaneId}
|
sidePanelPaneId={sidePanelPaneId}
|
||||||
maximizedPaneId={maximizedPaneId}
|
maximizedPaneId={maximizedPaneId}
|
||||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
@@ -1022,6 +1041,8 @@ function SplitNodeView({
|
|||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||||
|
paneIsolated={paneIsolated}
|
||||||
|
onClosePane={onClosePane}
|
||||||
onResizeSplit={onResizeSplit}
|
onResizeSplit={onResizeSplit}
|
||||||
onReorderTabsInPane={onReorderTabsInPane}
|
onReorderTabsInPane={onReorderTabsInPane}
|
||||||
renderPaneEmptyState={renderPaneEmptyState}
|
renderPaneEmptyState={renderPaneEmptyState}
|
||||||
@@ -1082,13 +1103,15 @@ function SplitPaneView({
|
|||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane: _onSplitPane,
|
onSplitPane: _onSplitPane,
|
||||||
onSplitPaneEmpty: _onSplitPaneEmpty,
|
onSplitPaneEmpty: _onSplitPaneEmpty,
|
||||||
|
paneIsolated,
|
||||||
|
onClosePane,
|
||||||
onReorderTabsInPane,
|
onReorderTabsInPane,
|
||||||
renderPaneEmptyState,
|
renderPaneEmptyState,
|
||||||
activeDragTabId,
|
activeDragTabId,
|
||||||
showDropZones,
|
showDropZones,
|
||||||
dropPreview,
|
dropPreview,
|
||||||
tabDropPreview,
|
tabDropPreview,
|
||||||
explorerPaneId,
|
sidePanelPaneId,
|
||||||
maximizedPaneId,
|
maximizedPaneId,
|
||||||
onTogglePaneMaximized,
|
onTogglePaneMaximized,
|
||||||
focusModeEnabled,
|
focusModeEnabled,
|
||||||
@@ -1195,9 +1218,15 @@ function SplitPaneView({
|
|||||||
() => onTogglePaneMaximized(paneId),
|
() => onTogglePaneMaximized(paneId),
|
||||||
[onTogglePaneMaximized, paneId],
|
[onTogglePaneMaximized, paneId],
|
||||||
);
|
);
|
||||||
|
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
|
||||||
return (
|
return (
|
||||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
<View
|
||||||
|
ref={paneRef}
|
||||||
|
collapsable={false}
|
||||||
|
style={styles.pane}
|
||||||
|
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
|
||||||
|
>
|
||||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
@@ -1228,7 +1257,7 @@ function SplitPaneView({
|
|||||||
tabDropPreviewIndex={
|
tabDropPreviewIndex={
|
||||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||||
}
|
}
|
||||||
showPaneMaximizeAction={pane.id === explorerPaneId && !focusModeEnabled}
|
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
|
||||||
paneMaximized={pane.id === maximizedPaneId}
|
paneMaximized={pane.id === maximizedPaneId}
|
||||||
onTogglePaneMaximized={handleToggleMaximized}
|
onTogglePaneMaximized={handleToggleMaximized}
|
||||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||||
@@ -1257,7 +1286,11 @@ function SplitPaneView({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: (renderPaneEmptyState?.() ?? null)}
|
: (renderPaneEmptyState?.({
|
||||||
|
paneId,
|
||||||
|
paneIsolated,
|
||||||
|
onClosePane: handleClosePane,
|
||||||
|
}) ?? null)}
|
||||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
|
import { MIN_TREE_RAIL_WIDTH } from "@/stores/panel-store";
|
||||||
|
|
||||||
const MIN_CONTENT_WIDTH = 240;
|
const MIN_CONTENT_WIDTH = 240;
|
||||||
|
|
||||||
export function resolveVisibleTreeRailWidth(
|
export function resolveVisibleTreeRailWidth(
|
||||||
requestedWidth: number,
|
requestedWidth: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
|
minimumWidth = MIN_TREE_RAIL_WIDTH,
|
||||||
): number {
|
): number {
|
||||||
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
|
const maximumVisibleWidth = Math.max(minimumWidth, containerWidth - MIN_CONTENT_WIDTH);
|
||||||
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
|
return Math.min(Math.max(minimumWidth, Math.min(600, requestedWidth)), maximumVisibleWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function acceptTreeRailContainerMeasurement(
|
export function acceptTreeRailContainerMeasurement(
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Pressable, Text } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderTree } from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
paneContentToolbarIconSize,
|
||||||
|
paneContentToolbarIconButtonStyle,
|
||||||
|
} from "@/components/ui/pane-content-toolbar";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||||
|
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens and closes a `TreeRail`. Every panel that owns a tree rail uses this one
|
||||||
|
* button so the affordance reads the same wherever the rail appears; only the
|
||||||
|
* flag it drives is per-panel.
|
||||||
|
*/
|
||||||
|
export function TreeRailToggle({
|
||||||
|
visible,
|
||||||
|
testID,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
testID: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = t(visible ? "workspace.tree.hideFolderTree" : "workspace.tree.showFolderTree");
|
||||||
|
const buttonStyle = useMemo(
|
||||||
|
() => (state: { hovered?: boolean; pressed: boolean }) =>
|
||||||
|
paneContentToolbarIconButtonStyle(state, visible),
|
||||||
|
[visible],
|
||||||
|
);
|
||||||
|
const accessibilityState = useMemo(() => ({ selected: visible }), [visible]);
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
aria-selected={visible}
|
||||||
|
testID={testID}
|
||||||
|
style={buttonStyle}
|
||||||
|
onPress={onToggle}
|
||||||
|
>
|
||||||
|
<ThemedFolderTree size={paneContentToolbarIconSize(false)} uniProps={iconColorMapping} />
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<Text style={styles.tooltipText}>{label}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
tooltipText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -27,16 +27,20 @@ export interface TreeRailProps {
|
|||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
width?: number;
|
||||||
|
onWidthChange?: (width: number) => void;
|
||||||
|
minimumWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
|
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
|
||||||
export function TreeRail({ children, testID }: TreeRailProps) {
|
export function TreeRail({ children, testID, width, onWidthChange, minimumWidth }: TreeRailProps) {
|
||||||
const [content, tree] = Children.toArray(children);
|
const [content, tree] = Children.toArray(children);
|
||||||
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
|
const sharedWidth = usePanelStore((state) => state.treeRailWidth);
|
||||||
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
|
const setSharedWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||||
|
const requestedWidth = width ?? sharedWidth;
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
const [containerWidth, setContainerWidth] = useState(viewportWidth);
|
const [containerWidth, setContainerWidth] = useState(viewportWidth);
|
||||||
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
|
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth, minimumWidth);
|
||||||
const startWidthRef = useRef(visibleWidth);
|
const startWidthRef = useRef(visibleWidth);
|
||||||
const resizeWidth = useSharedValue(visibleWidth);
|
const resizeWidth = useSharedValue(visibleWidth);
|
||||||
const [resizePressed, setResizePressed] = useState(false);
|
const [resizePressed, setResizePressed] = useState(false);
|
||||||
@@ -62,11 +66,21 @@ export function TreeRail({ children, testID }: TreeRailProps) {
|
|||||||
resizeWidth.value = resolveVisibleTreeRailWidth(
|
resizeWidth.value = resolveVisibleTreeRailWidth(
|
||||||
startWidthRef.current - event.translationX,
|
startWidthRef.current - event.translationX,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
|
minimumWidth,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
|
.onEnd(() => runOnJS(onWidthChange ?? setSharedWidth)(resizeWidth.value))
|
||||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||||
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
|
[
|
||||||
|
containerWidth,
|
||||||
|
hideResizeGrip,
|
||||||
|
onWidthChange,
|
||||||
|
minimumWidth,
|
||||||
|
resizeWidth,
|
||||||
|
setSharedWidth,
|
||||||
|
showResizeGrip,
|
||||||
|
visibleWidth,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
|
|
||||||
|
/** Shared chrome at the boundary between a workspace pane's tabs and content. */
|
||||||
|
export function PaneContentToolbar({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.toolbar, style]} testID={testID}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paneContentToolbarIconSize(isCompact: boolean): number {
|
||||||
|
return isCompact ? 18 : 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paneContentToolbarIconButtonStyle(
|
||||||
|
{ hovered, pressed }: { hovered?: boolean; pressed: boolean },
|
||||||
|
selected = false,
|
||||||
|
isCompact = false,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
isCompact ? styles.iconButtonCompact : styles.iconButton,
|
||||||
|
(selected || Boolean(hovered) || pressed) && styles.iconButtonSelected,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
toolbar: {
|
||||||
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
iconButtonCompact: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
iconButtonSelected: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -39,6 +39,7 @@ export function Shortcut({
|
|||||||
if (displayChord.length === 1) {
|
if (displayChord.length === 1) {
|
||||||
return (
|
return (
|
||||||
<View style={badgeStyle}>
|
<View style={badgeStyle}>
|
||||||
|
<View style={styles.badgeBackground} />
|
||||||
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
|
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -49,6 +50,7 @@ export function Shortcut({
|
|||||||
{displayChord.map(function (combo) {
|
{displayChord.map(function (combo) {
|
||||||
return (
|
return (
|
||||||
<View key={combo.join("+")} style={styles.badge}>
|
<View key={combo.join("+")} style={styles.badge}>
|
||||||
|
<View style={styles.badgeBackground} />
|
||||||
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
|
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -59,12 +61,18 @@ export function Shortcut({
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
badge: {
|
badge: {
|
||||||
|
position: "relative",
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
|
badgeBackground: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surface4,
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
sequence: {
|
sequence: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
type WorkspaceDraftTabSetup,
|
type WorkspaceDraftTabSetup,
|
||||||
} from "@/workspace-tabs/model";
|
} from "@/workspace-tabs/model";
|
||||||
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
|
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||||
@@ -451,7 +451,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (attachment.kind !== "review") {
|
if (attachment.kind !== "review") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openExplorerSurface({
|
openSidePanelView({
|
||||||
isCompact: isCompactFormFactor,
|
isCompact: isCompactFormFactor,
|
||||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
|
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
|
||||||
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
||||||
|
|||||||
@@ -69,6 +69,50 @@ describe("focused chat target", () => {
|
|||||||
parentTabIdByTabId: { "changes-tab": "agent-tab" },
|
parentTabIdByTabId: { "changes-tab": "agent-tab" },
|
||||||
} as WorkspaceLayout;
|
} as WorkspaceLayout;
|
||||||
|
|
||||||
|
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
|
||||||
|
tabId: "agent-tab",
|
||||||
|
draftKey: "agent:server-1:agent-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("targets a chat in a neighbouring pane when the focused pane holds none", () => {
|
||||||
|
const layout = {
|
||||||
|
root: {
|
||||||
|
kind: "group",
|
||||||
|
group: {
|
||||||
|
id: "group-1",
|
||||||
|
direction: "horizontal",
|
||||||
|
sizes: [0.5, 0.5],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "main",
|
||||||
|
tabIds: ["agent-tab"],
|
||||||
|
focusedTabId: "agent-tab",
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabId: "agent-tab",
|
||||||
|
target: { kind: "agent", agentId: "agent-1" },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "explorer",
|
||||||
|
tabIds: ["changes-tab"],
|
||||||
|
focusedTabId: "changes-tab",
|
||||||
|
tabs: [{ tabId: "changes-tab", target: { kind: "working_diff" }, createdAt: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focusedPaneId: "explorer",
|
||||||
|
} as unknown as WorkspaceLayout;
|
||||||
|
|
||||||
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
|
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
|
||||||
tabId: "agent-tab",
|
tabId: "agent-tab",
|
||||||
draftKey: "agent:server-1:agent-1",
|
draftKey: "agent:server-1:agent-1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import {
|
import {
|
||||||
|
collectAllPanes,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
findPaneById,
|
findPaneById,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
@@ -33,6 +34,12 @@ function resolveChatTab(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat a file should attach to. The focused pane answers first, then the tab it
|
||||||
|
* came from — a file opened beside a chat belongs to that chat. Failing both, any
|
||||||
|
* chat the user can see: standing in the Side panel looking at Changes, "add to
|
||||||
|
* chat" plainly means the chat in the pane next to it.
|
||||||
|
*/
|
||||||
export function resolveFocusedChatTarget(input: {
|
export function resolveFocusedChatTarget(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
layout: WorkspaceLayout | undefined;
|
layout: WorkspaceLayout | undefined;
|
||||||
@@ -40,20 +47,34 @@ export function resolveFocusedChatTarget(input: {
|
|||||||
if (!input.layout) {
|
if (!input.layout) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
|
||||||
const focusedTabId = pane?.focusedTabId;
|
|
||||||
if (!focusedTabId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tabs = collectAllTabs(input.layout.root);
|
const tabs = collectAllTabs(input.layout.root);
|
||||||
const tab = tabs.find((candidate) => candidate.tabId === focusedTabId);
|
const focusedTabId = findPaneById(input.layout.root, input.layout.focusedPaneId)?.focusedTabId;
|
||||||
const focusedChat = resolveChatTab(input.serverId, tab);
|
if (focusedTabId) {
|
||||||
if (focusedChat) {
|
const focusedChat = resolveChatTab(
|
||||||
return focusedChat;
|
input.serverId,
|
||||||
|
tabs.find((candidate) => candidate.tabId === focusedTabId),
|
||||||
|
);
|
||||||
|
if (focusedChat) {
|
||||||
|
return focusedChat;
|
||||||
|
}
|
||||||
|
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
|
||||||
|
const parentChat = resolveChatTab(
|
||||||
|
input.serverId,
|
||||||
|
tabs.find((candidate) => candidate.tabId === parentTabId),
|
||||||
|
);
|
||||||
|
if (parentChat) {
|
||||||
|
return parentChat;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
|
|
||||||
return resolveChatTab(
|
for (const pane of collectAllPanes(input.layout.root)) {
|
||||||
input.serverId,
|
const paneChat = resolveChatTab(
|
||||||
tabs.find((candidate) => candidate.tabId === parentTabId),
|
input.serverId,
|
||||||
);
|
tabs.find((candidate) => candidate.tabId === pane.focusedTabId),
|
||||||
|
);
|
||||||
|
if (paneChat) {
|
||||||
|
return paneChat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,9 +291,11 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
throw new Error("Expected workspace key");
|
throw new Error("Expected workspace key");
|
||||||
}
|
}
|
||||||
const previousFocusedTabId = useWorkspaceLayoutStore
|
const previousFocusedTabId = useWorkspaceLayoutStore.getState().openTab({
|
||||||
.getState()
|
workspaceKey: workspaceKey,
|
||||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
target: { kind: "draft", draftId: "human-draft" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
browser.mount({ serverId: "server-1" });
|
browser.mount({ serverId: "server-1" });
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
|
|||||||
@@ -336,9 +336,10 @@ async function openBrowserTabForRequest(params: {
|
|||||||
message: "Cannot create a browser tab without a workspace context.",
|
message: "Cannot create a browser tab without a workspace context.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
|
useWorkspaceLayoutStore.getState().openTab({
|
||||||
kind: "browser",
|
workspaceKey,
|
||||||
browserId,
|
target: { kind: "browser", browserId },
|
||||||
|
intent: "background",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (browserHost?.executeAutomationCommand) {
|
if (browserHost?.executeAutomationCommand) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
import { createDefaultLayout } from "@/stores/workspace-layout-store";
|
import { createDefaultLayout } from "@/stores/workspace-layout-store";
|
||||||
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
|
import { FOCUSED_PANE_PLACEMENT, openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
|
||||||
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
|
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
|
||||||
|
|
||||||
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
|
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
|
||||||
@@ -9,7 +9,8 @@ function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
|
|||||||
layout: createDefaultLayout(),
|
layout: createDefaultLayout(),
|
||||||
target: { kind: "browser", browserId },
|
target: { kind: "browser", browserId },
|
||||||
now: 1,
|
now: 1,
|
||||||
explorerPaneId: null,
|
placement: FOCUSED_PANE_PLACEMENT,
|
||||||
|
sidePanelPaneId: null,
|
||||||
}).layout;
|
}).layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function BrowserPanel() {
|
|||||||
|
|
||||||
export const browserPanelRegistration: PanelRegistration<"browser"> = {
|
export const browserPanelRegistration: PanelRegistration<"browser"> = {
|
||||||
kind: "browser",
|
kind: "browser",
|
||||||
|
resourceKey: (target) => target.browserId,
|
||||||
component: BrowserPanel,
|
component: BrowserPanel,
|
||||||
useDescriptor: useBrowserPanelDescriptor,
|
useDescriptor: useBrowserPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Text, View } from "react-native";
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||||
import type { FileEditorStatus } from "./editor/model";
|
import type { FileEditorStatus } from "./editor/model";
|
||||||
|
import { FileTreeToggle } from "./tree-toggle";
|
||||||
|
|
||||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
@@ -39,74 +41,77 @@ export function FilePanelBar({
|
|||||||
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<View style={styles.chrome} testID="file-panel-bar">
|
<View style={styles.chrome}>
|
||||||
<View style={styles.row}>
|
<PaneContentToolbar testID="file-panel-bar">
|
||||||
<View style={styles.metadata}>
|
<View style={styles.row}>
|
||||||
<Text
|
<View style={styles.metadata}>
|
||||||
style={styles.whisper}
|
|
||||||
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
|
||||||
>
|
|
||||||
{formatFileSize(size)}
|
|
||||||
</Text>
|
|
||||||
{lineCount !== undefined ? (
|
|
||||||
<Text
|
<Text
|
||||||
style={styles.whisper}
|
style={styles.whisper}
|
||||||
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||||
>
|
>
|
||||||
{t("panels.file.editor.lines", { count: lineCount })}
|
{formatFileSize(size)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
{lineCount !== undefined ? (
|
||||||
</View>
|
<Text
|
||||||
<View
|
style={styles.whisper}
|
||||||
style={styles.status}
|
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||||
accessibilityLabel={
|
>
|
||||||
editorStatus
|
{t("panels.file.editor.lines", { count: lineCount })}
|
||||||
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
</Text>
|
||||||
: undefined
|
) : null}
|
||||||
}
|
</View>
|
||||||
>
|
<View
|
||||||
{editorStatus === "dirty" ? (
|
style={styles.status}
|
||||||
<View
|
accessibilityLabel={
|
||||||
style={styles.dirtyDot}
|
editorStatus
|
||||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editorStatus === "dirty" ? (
|
||||||
|
<View
|
||||||
|
style={styles.dirtyDot}
|
||||||
|
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "saving" ? (
|
||||||
|
<>
|
||||||
|
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||||
|
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "error" ? (
|
||||||
|
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||||
|
) : null}
|
||||||
|
{vimMode ? (
|
||||||
|
<Text
|
||||||
|
style={styles.vim}
|
||||||
|
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||||
|
>
|
||||||
|
{vimMode}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{cursor ? (
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
||||||
|
>
|
||||||
|
Ln {cursor.line}, Col {cursor.column}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{mode && onModeChange ? (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={onModeChange}
|
||||||
|
testID="file-preview-mode"
|
||||||
|
options={previewModes}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{editorStatus === "saving" ? (
|
<FileTreeToggle />
|
||||||
<>
|
|
||||||
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
|
||||||
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{editorStatus === "error" ? (
|
|
||||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
|
||||||
) : null}
|
|
||||||
{vimMode ? (
|
|
||||||
<Text
|
|
||||||
style={styles.vim}
|
|
||||||
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
|
||||||
>
|
|
||||||
{vimMode}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{cursor ? (
|
|
||||||
<Text
|
|
||||||
style={styles.whisper}
|
|
||||||
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
|
||||||
>
|
|
||||||
Ln {cursor.line}, Col {cursor.column}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
{mode && onModeChange ? (
|
</PaneContentToolbar>
|
||||||
<SegmentedControl
|
|
||||||
size="xs"
|
|
||||||
value={mode}
|
|
||||||
onValueChange={onModeChange}
|
|
||||||
testID="file-preview-mode"
|
|
||||||
options={previewModes}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -121,16 +126,13 @@ function formatFileSize(size: number): string {
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
chrome: {
|
chrome: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
minHeight: 32,
|
height: "100%",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -40,9 +40,15 @@ export function useLiveFile(input: {
|
|||||||
return {
|
return {
|
||||||
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
|
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
|
||||||
error: snapshot.read.status === "error" ? snapshot.read.error : null,
|
error: snapshot.read.status === "error" ? snapshot.read.error : null,
|
||||||
isFetching: snapshot.read.status === "pending",
|
// Effects start reads after the first render. An enabled target is therefore
|
||||||
|
// already in the producer's initial/retargeting display state.
|
||||||
|
isFetching:
|
||||||
|
input.enabled &&
|
||||||
|
(snapshot.read.status === "pending" ||
|
||||||
|
(snapshot.read.status === "idle" && snapshot.observation === null)),
|
||||||
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
|
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
|
||||||
refresh: model.refresh,
|
refresh: model.refresh,
|
||||||
model,
|
model,
|
||||||
|
snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,31 @@ async function waitForRead(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("LiveFileModel", () => {
|
describe("LiveFileModel", () => {
|
||||||
|
it("reports initial and read-pending work before any terminal observation", async () => {
|
||||||
|
const session = new TestLiveFileSession();
|
||||||
|
const model = new LiveFileModel();
|
||||||
|
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
|
||||||
|
await waitForRead(session, 1);
|
||||||
|
expect(model.getSnapshot()).toEqual({
|
||||||
|
observation: null,
|
||||||
|
read: { status: "pending", requested: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports read error as terminal and recovers on refresh", async () => {
|
||||||
|
const session = new TestLiveFileSession();
|
||||||
|
const model = new LiveFileModel();
|
||||||
|
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
|
||||||
|
await waitForRead(session, 1);
|
||||||
|
session.reads[0].reject(new Error("read failed"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }),
|
||||||
|
);
|
||||||
|
model.refresh();
|
||||||
|
await waitForRead(session, 2);
|
||||||
|
session.reads[1].resolve(file("revision-2"));
|
||||||
|
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
|
||||||
|
});
|
||||||
it("does not publish a missing candidate when the following read succeeds", async () => {
|
it("does not publish a missing candidate when the following read succeeds", async () => {
|
||||||
const session = new TestLiveFileSession();
|
const session = new TestLiveFileSession();
|
||||||
const model = new LiveFileModel();
|
const model = new LiveFileModel();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
|
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -20,11 +20,8 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||||
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
import { getFileNameFromPath } from "@/attachments/utils";
|
||||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
|
||||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
|
||||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
@@ -33,6 +30,8 @@ import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useLiveFile } from "./live-file/hook";
|
import { useLiveFile } from "./live-file/hook";
|
||||||
|
import { useFilePreview } from "./preview-lifecycle/hook";
|
||||||
|
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
|
||||||
import { FilePanelBar } from "./bar";
|
import { FilePanelBar } from "./bar";
|
||||||
import { FileHtmlPreview } from "./html-preview";
|
import { FileHtmlPreview } from "./html-preview";
|
||||||
import { FileMarkdownPreview } from "./markdown-preview";
|
import { FileMarkdownPreview } from "./markdown-preview";
|
||||||
@@ -92,38 +91,6 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
|
||||||
file: ExplorerFile | null;
|
|
||||||
imageAttachment: AttachmentMetadata | null;
|
|
||||||
}> {
|
|
||||||
if (!file) {
|
|
||||||
return { file: null, imageAttachment: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const explorerFile = explorerFileFromReadResult(file);
|
|
||||||
if (file.kind !== "image") {
|
|
||||||
return { file: explorerFile, imageAttachment: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageAttachment = await persistAttachmentFromBytes({
|
|
||||||
id: createPreviewAttachmentId({
|
|
||||||
mimeType: file.mime,
|
|
||||||
path: file.path,
|
|
||||||
size: file.size,
|
|
||||||
modifiedAt: file.modifiedAt,
|
|
||||||
contentLength: file.bytes.byteLength,
|
|
||||||
}),
|
|
||||||
bytes: file.bytes,
|
|
||||||
mimeType: file.mime,
|
|
||||||
fileName: getFileNameFromPath(file.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
file: explorerFile,
|
|
||||||
imageAttachment,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampLineSelection(input: {
|
function clampLineSelection(input: {
|
||||||
lineStart?: number;
|
lineStart?: number;
|
||||||
lineEnd?: number;
|
lineEnd?: number;
|
||||||
@@ -274,7 +241,7 @@ function FilePreviewBody({
|
|||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState} testID="file-preview-loading">
|
||||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -283,7 +250,7 @@ function FilePreviewBody({
|
|||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState} testID="file-preview-unsupported">
|
||||||
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -411,11 +378,6 @@ export function FilePane({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
|
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
|
||||||
const [resolvedPreview, setResolvedPreview] = useState<{
|
|
||||||
key: string | null;
|
|
||||||
file: ExplorerFile | null;
|
|
||||||
imageAttachment: AttachmentMetadata | null;
|
|
||||||
}>({ key: null, file: null, imageAttachment: null });
|
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||||
@@ -453,26 +415,16 @@ export function FilePane({
|
|||||||
liveUpdates: supportsEditing,
|
liveUpdates: supportsEditing,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||||
if (!liveFile.file) return;
|
const previewLifecycle = useFilePreview({
|
||||||
let active = true;
|
targetKey,
|
||||||
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
liveFileSnapshot: liveFile.snapshot,
|
||||||
void (async () => {
|
});
|
||||||
const nextPreview = await createFilePanePreview(liveFile.file);
|
|
||||||
if (active) setResolvedPreview({ key, ...nextPreview });
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [liveFile.file, readTarget]);
|
|
||||||
|
|
||||||
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
useEffect(() => setPreviewMode("preview"), [targetKey]);
|
||||||
useEffect(() => setPreviewMode("preview"), [previewKey]);
|
|
||||||
|
|
||||||
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
|
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
|
||||||
const imagePreviewUri = useAttachmentPreviewUrl(
|
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
|
||||||
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
|
|
||||||
);
|
|
||||||
const isRenderable = isRenderablePreview(preview, location.path);
|
const isRenderable = isRenderablePreview(preview, location.path);
|
||||||
const editable = isEditableTextFile({
|
const editable = isEditableTextFile({
|
||||||
preview,
|
preview,
|
||||||
@@ -481,7 +433,11 @@ export function FilePane({
|
|||||||
const canTogglePreviewMode = isRenderable && !location.lineStart;
|
const canTogglePreviewMode = isRenderable && !location.lineStart;
|
||||||
const lineCount =
|
const lineCount =
|
||||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||||
const errorMessage = getFileErrorMessage(liveFile.error, t("panels.file.failedToLoad"));
|
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
|
||||||
|
const isLoading =
|
||||||
|
previewLifecycle.status === "initial" ||
|
||||||
|
previewLifecycle.status === "read_pending" ||
|
||||||
|
previewLifecycle.status === "preparing";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilePanePresentation
|
<FilePanePresentation
|
||||||
@@ -500,7 +456,7 @@ export function FilePane({
|
|||||||
editable={editable}
|
editable={editable}
|
||||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
isLoading={liveFile.isFetching}
|
isLoading={isLoading}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
location={location}
|
location={location}
|
||||||
navigationRevision={navigationRevision}
|
navigationRevision={navigationRevision}
|
||||||
@@ -513,12 +469,6 @@ function isRenderablePreview(preview: ExplorerFile | null, path: string): boolea
|
|||||||
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
|
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
|
||||||
if (!error) return null;
|
|
||||||
if (typeof error === "string") return error;
|
|
||||||
return error instanceof Error ? error.message : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTextFile(input: {
|
function isEditableTextFile(input: {
|
||||||
preview: ExplorerFile | null;
|
preview: ExplorerFile | null;
|
||||||
supportsEditing: boolean;
|
supportsEditing: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
import type { LiveFileSnapshot } from "../live-file/model";
|
||||||
|
import {
|
||||||
|
createFilePanePreview,
|
||||||
|
FilePreviewLifecycleModel,
|
||||||
|
type FilePreviewLifecycleSnapshot,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
/** Bridges the raw-file owner to the preview preparation owner. */
|
||||||
|
export function useFilePreview(input: {
|
||||||
|
targetKey: string | null;
|
||||||
|
liveFileSnapshot: LiveFileSnapshot;
|
||||||
|
}): FilePreviewLifecycleSnapshot {
|
||||||
|
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
|
||||||
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
|
const { targetKey, liveFileSnapshot } = input;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
model.setSource({ targetKey, liveFileSnapshot });
|
||||||
|
}, [liveFileSnapshot, model, targetKey]);
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { LiveFileSnapshot } from "../live-file/model";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
FilePreviewLifecycleModel,
|
||||||
|
type FilePanePreview,
|
||||||
|
type FilePreviewLifecycleSnapshot,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
function previewFile(content: string) {
|
||||||
|
return {
|
||||||
|
kind: "text" as const,
|
||||||
|
path: "file.ts",
|
||||||
|
size: 7,
|
||||||
|
encoding: "utf-8" as const,
|
||||||
|
hasBom: false,
|
||||||
|
modifiedAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve(value: T): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<T>((onResolve, onReject) => {
|
||||||
|
resolve = onResolve;
|
||||||
|
reject = onReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function file(revision = "revision-1", path = "file.ts"): FileReadResult {
|
||||||
|
return {
|
||||||
|
bytes: new TextEncoder().encode("preview"),
|
||||||
|
mime: "text/plain",
|
||||||
|
size: 7,
|
||||||
|
path,
|
||||||
|
kind: "text",
|
||||||
|
modifiedAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pending(): LiveFileSnapshot {
|
||||||
|
return { observation: null, read: { status: "pending", requested: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function completed(rawFile: FileReadResult): LiveFileSnapshot {
|
||||||
|
return {
|
||||||
|
observation: {
|
||||||
|
status: "ready",
|
||||||
|
file: rawFile,
|
||||||
|
version: {
|
||||||
|
status: "ready",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: rawFile.path,
|
||||||
|
size: rawFile.size,
|
||||||
|
modifiedAt: rawFile.modifiedAt,
|
||||||
|
revision: rawFile.revision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
read: { status: "idle" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function source(
|
||||||
|
targetKey: string,
|
||||||
|
snapshot: LiveFileSnapshot,
|
||||||
|
): {
|
||||||
|
targetKey: string;
|
||||||
|
liveFileSnapshot: LiveFileSnapshot;
|
||||||
|
} {
|
||||||
|
return { targetKey, liveFileSnapshot: snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FilePreviewLifecycleModel", () => {
|
||||||
|
it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => {
|
||||||
|
const first = deferred<FilePanePreview | null>();
|
||||||
|
const second = deferred<FilePanePreview | null>();
|
||||||
|
const third = deferred<FilePanePreview | null>();
|
||||||
|
const preparations = [first, second, third];
|
||||||
|
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
|
||||||
|
const preview: FilePanePreview = {
|
||||||
|
file: previewFile("preview"),
|
||||||
|
imageAttachment: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
model.setSource(source("/workspace:file.ts", pending()));
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "read_pending" });
|
||||||
|
|
||||||
|
model.setSource(source("/workspace:file.ts", completed(file("one"))));
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "preparing" });
|
||||||
|
first.resolve(preview);
|
||||||
|
await expectSnapshot(model, { status: "ready", preview });
|
||||||
|
|
||||||
|
model.setSource(source("/workspace:file.ts", pending()));
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
|
||||||
|
|
||||||
|
model.setSource(
|
||||||
|
source("/workspace:file.ts", {
|
||||||
|
observation: {
|
||||||
|
status: "error",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "file.ts",
|
||||||
|
error: "Requested path is not a file",
|
||||||
|
},
|
||||||
|
read: { status: "pending", requested: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
|
||||||
|
|
||||||
|
model.setSource(source("/workspace:file.ts", completed(file("two"))));
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "preparing", preview });
|
||||||
|
second.resolve(null);
|
||||||
|
await expectSnapshot(model, { status: "unsupported" });
|
||||||
|
|
||||||
|
model.setSource(source("/workspace:file.ts", completed(file("three"))));
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "preparing" });
|
||||||
|
third.reject(new Error("attachment failed"));
|
||||||
|
await expectSnapshot(model, { status: "error", message: "attachment failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish a stale conversion after retargeting", async () => {
|
||||||
|
const first = deferred<FilePanePreview | null>();
|
||||||
|
const second = deferred<FilePanePreview | null>();
|
||||||
|
const model = new FilePreviewLifecycleModel((rawFile) =>
|
||||||
|
rawFile.revision === "one" ? first.promise : second.promise,
|
||||||
|
);
|
||||||
|
const nextPreview: FilePanePreview = {
|
||||||
|
file: previewFile("two"),
|
||||||
|
imageAttachment: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
model.setSource(source("/workspace:file.ts", completed(file("one"))));
|
||||||
|
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
|
||||||
|
first.resolve({ file: previewFile("one"), imageAttachment: null });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(model.getSnapshot()).toEqual({ status: "preparing" });
|
||||||
|
|
||||||
|
second.resolve(nextPreview);
|
||||||
|
await expectSnapshot(model, { status: "ready", preview: nextPreview });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expectSnapshot(
|
||||||
|
model: FilePreviewLifecycleModel,
|
||||||
|
expected: FilePreviewLifecycleSnapshot,
|
||||||
|
): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||||
|
expect(model.getSnapshot()).toEqual(expected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||||
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||||
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
|
import type { ExplorerFile } from "@/stores/session-store";
|
||||||
|
import type { LiveFileSnapshot } from "../live-file/model";
|
||||||
|
|
||||||
|
export interface FilePanePreview {
|
||||||
|
file: ExplorerFile;
|
||||||
|
imageAttachment: AttachmentMetadata | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilePreviewLifecycleSnapshot =
|
||||||
|
| { status: "initial" }
|
||||||
|
| { status: "read_pending"; preview?: FilePanePreview }
|
||||||
|
| { status: "preparing"; preview?: FilePanePreview }
|
||||||
|
| { status: "ready"; preview: FilePanePreview }
|
||||||
|
| { status: "error"; message: string; preview?: FilePanePreview }
|
||||||
|
| { status: "unsupported" };
|
||||||
|
|
||||||
|
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
|
||||||
|
|
||||||
|
/** Converts a completed raw read into the preview resources consumed by FilePane. */
|
||||||
|
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
|
||||||
|
const explorerFile = explorerFileFromReadResult(file);
|
||||||
|
if (file.kind !== "image") {
|
||||||
|
return { file: explorerFile, imageAttachment: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageAttachment = await persistAttachmentFromBytes({
|
||||||
|
id: createPreviewAttachmentId({
|
||||||
|
mimeType: file.mime,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
contentLength: file.bytes.byteLength,
|
||||||
|
}),
|
||||||
|
bytes: file.bytes,
|
||||||
|
mimeType: file.mime,
|
||||||
|
fileName: getFileNameFromPath(file.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { file: explorerFile, imageAttachment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
|
||||||
|
export class FilePreviewLifecycleModel {
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot;
|
||||||
|
private generation = 0;
|
||||||
|
private preparedSourceKey: string | null = null;
|
||||||
|
private targetKey: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
subscribe = (listener: () => void): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
|
||||||
|
const targetChanged = input.targetKey !== this.targetKey;
|
||||||
|
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
|
||||||
|
if (targetChanged) {
|
||||||
|
this.targetKey = input.targetKey;
|
||||||
|
this.preparedSourceKey = null;
|
||||||
|
this.generation += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = getReadySource(input);
|
||||||
|
if (source) {
|
||||||
|
if (source.key === this.preparedSourceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.preparedSourceKey = source.key;
|
||||||
|
const generation = ++this.generation;
|
||||||
|
this.setSnapshot({
|
||||||
|
status: "preparing",
|
||||||
|
...(retainedPreview ? { preview: retainedPreview } : {}),
|
||||||
|
});
|
||||||
|
void this.prepareSource({ file: source.file, generation, retainedPreview });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot);
|
||||||
|
if (nextSnapshot.status === "initial" && retainedPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retainedSnapshot = retainedPreview
|
||||||
|
? { ...nextSnapshot, preview: retainedPreview }
|
||||||
|
: nextSnapshot;
|
||||||
|
if (sameNonReadyState(this.snapshot, retainedSnapshot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.preparedSourceKey = null;
|
||||||
|
this.generation += 1;
|
||||||
|
this.setSnapshot(retainedSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void {
|
||||||
|
this.snapshot = snapshot;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSource(input: {
|
||||||
|
file: FileReadResult;
|
||||||
|
generation: number;
|
||||||
|
retainedPreview?: FilePanePreview;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const preview = await this.prepare(input.file);
|
||||||
|
if (input.generation !== this.generation) return;
|
||||||
|
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
|
||||||
|
} catch (error) {
|
||||||
|
if (input.generation !== this.generation) return;
|
||||||
|
this.setSnapshot({
|
||||||
|
status: "error",
|
||||||
|
message: errorMessage(error),
|
||||||
|
...(input.retainedPreview ? { preview: input.retainedPreview } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filePreviewFromLifecycle(
|
||||||
|
snapshot: FilePreviewLifecycleSnapshot,
|
||||||
|
): FilePanePreview | undefined {
|
||||||
|
return "preview" in snapshot ? snapshot.preview : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
|
||||||
|
file: ExplorerFile | null;
|
||||||
|
imageAttachment: AttachmentMetadata | null;
|
||||||
|
} {
|
||||||
|
const preview = filePreviewFromLifecycle(snapshot);
|
||||||
|
return preview ?? { file: null, imageAttachment: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadySource(input: {
|
||||||
|
targetKey: string | null;
|
||||||
|
liveFileSnapshot: LiveFileSnapshot;
|
||||||
|
}): { key: string; file: FileReadResult } | null {
|
||||||
|
const observation = input.liveFileSnapshot.observation;
|
||||||
|
if (!input.targetKey || observation?.status !== "ready") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { version } = observation;
|
||||||
|
if (input.targetKey !== `${version.cwd}:${version.path}`) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`,
|
||||||
|
file: observation.file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot {
|
||||||
|
if (snapshot.read.status === "error") {
|
||||||
|
return { status: "error", message: snapshot.read.error };
|
||||||
|
}
|
||||||
|
if (snapshot.read.status === "pending") {
|
||||||
|
return { status: "read_pending" };
|
||||||
|
}
|
||||||
|
if (snapshot.observation?.status === "error") {
|
||||||
|
return { status: "error", message: snapshot.observation.error };
|
||||||
|
}
|
||||||
|
if (snapshot.observation?.status === "missing") {
|
||||||
|
return { status: "error", message: "File not found" };
|
||||||
|
}
|
||||||
|
return initialSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameNonReadyState(
|
||||||
|
current: FilePreviewLifecycleSnapshot,
|
||||||
|
next: FilePreviewLifecycleSnapshot,
|
||||||
|
): boolean {
|
||||||
|
if (current.status !== next.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return current.status !== "error" || next.status !== "error" || current.message === next.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : "Failed to prepare preview";
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { TreeRailToggle } from "@/components/tree-rail-toggle";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { defaultFileState, fileStateSchema } from "@/panels/file/state";
|
||||||
|
import { usePanelState } from "@/panels/use-panel-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-wired so `FilePanelBar` stays a leaf of the file pane tree — the bar is
|
||||||
|
* rendered several components below `FilePanel`, which is where the rail lives,
|
||||||
|
* and threading a callback down that path buys nothing.
|
||||||
|
*/
|
||||||
|
export function FileTreeToggle() {
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const [fileState, setFileState] = usePanelState(fileStateSchema, defaultFileState);
|
||||||
|
const visible = fileState.treeVisible;
|
||||||
|
const toggle = useCallback(
|
||||||
|
() => setFileState({ ...fileState, treeVisible: !visible }),
|
||||||
|
[fileState, setFileState, visible],
|
||||||
|
);
|
||||||
|
// Compact layouts reach the tree through the explorer panel; there is no rail
|
||||||
|
// beside the file to open or close.
|
||||||
|
if (isCompact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <TreeRailToggle visible={visible} testID="file-toggle-tree" onToggle={toggle} />;
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { Pressable, Text, View } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
|
||||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||||
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
@@ -13,6 +12,8 @@ interface CommitsSectionProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
onCommitPress: (sha: string) => void;
|
onCommitPress: (sha: string) => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommitsSectionSkeleton() {
|
function CommitsSectionSkeleton() {
|
||||||
@@ -82,11 +83,15 @@ function CommitsSectionContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
|
export function CommitsSection({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
onCommitPress,
|
||||||
|
collapsed = true,
|
||||||
|
onCollapsedChange,
|
||||||
|
}: CommitsSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { preferences, updatePreferences } = useChangesPreferences();
|
|
||||||
const isPanelActive = useRetainedPanelActive();
|
const isPanelActive = useRetainedPanelActive();
|
||||||
const collapsed = preferences.commitsCollapsed;
|
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||||
const query = useCheckoutCommitsQuery({
|
const query = useCheckoutCommitsQuery({
|
||||||
@@ -99,8 +104,8 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
|||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
setNow(new Date());
|
setNow(new Date());
|
||||||
}
|
}
|
||||||
void updatePreferences({ commitsCollapsed: !collapsed });
|
onCollapsedChange?.(!collapsed);
|
||||||
}, [collapsed, updatePreferences]);
|
}, [collapsed, onCollapsedChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collapsed || !isPanelActive) {
|
if (collapsed || !isPanelActive) {
|
||||||
|
|||||||
+130
-232
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, type ReactElement } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { TreeRail } from "@/components/tree-rail";
|
import { TreeRail } from "@/components/tree-rail";
|
||||||
|
import { TreeRailToggle } from "@/components/tree-rail-toggle";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -13,13 +14,12 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
AlignJustify,
|
AlignJustify,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Columns2,
|
Columns2,
|
||||||
FolderTree,
|
|
||||||
ListChevronsDownUp,
|
ListChevronsDownUp,
|
||||||
ListChevronsUpDown,
|
ListChevronsUpDown,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
WrapText,
|
WrapText,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { type ParsedDiffFile } from "@/git/use-diff-query";
|
import { type ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
|
import type { ChangesState } from "@/panels/changes/state";
|
||||||
|
import { defaultChangesState } from "@/panels/changes/state";
|
||||||
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
|
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
|
||||||
import { FileHeader } from "@/git/file-header";
|
import { FileHeader } from "@/git/file-header";
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +43,6 @@ import {
|
|||||||
import { DiffFolderRow } from "@/git/diff-folder-row";
|
import { DiffFolderRow } from "@/git/diff-folder-row";
|
||||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||||
import { CommitsSection } from "@/git/commits-section/commits-section";
|
import { CommitsSection } from "@/git/commits-section/commits-section";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -68,10 +69,15 @@ import { useToast } from "@/contexts/toast-context";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import {
|
||||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
PaneContentToolbar,
|
||||||
|
paneContentToolbarIconSize,
|
||||||
|
paneContentToolbarIconButtonStyle,
|
||||||
|
} from "@/components/ui/pane-content-toolbar";
|
||||||
|
import { FOCUSED_PANE_PLACEMENT, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
|
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||||
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||||
@@ -163,6 +169,8 @@ interface ChangesSurfaceProps {
|
|||||||
focusRequestId?: number;
|
focusRequestId?: number;
|
||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
onAddToChat?: (path: string) => void;
|
onAddToChat?: (path: string) => void;
|
||||||
|
state?: ChangesState;
|
||||||
|
onStateChange?: (state: ChangesState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PressableStyleFn = (
|
type PressableStyleFn = (
|
||||||
@@ -176,10 +184,10 @@ const ThemedAlignJustify = withUnistyles(AlignJustify);
|
|||||||
const ThemedColumns2 = withUnistyles(Columns2);
|
const ThemedColumns2 = withUnistyles(Columns2);
|
||||||
const ThemedPilcrow = withUnistyles(Pilcrow);
|
const ThemedPilcrow = withUnistyles(Pilcrow);
|
||||||
const ThemedWrapText = withUnistyles(WrapText);
|
const ThemedWrapText = withUnistyles(WrapText);
|
||||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
|
||||||
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
|
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
|
||||||
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
||||||
const ThemedMaximize2 = withUnistyles(Maximize2);
|
const ThemedMaximize2 = withUnistyles(Maximize2);
|
||||||
|
const noopStateChange = () => {};
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
|
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
|
||||||
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
||||||
@@ -216,8 +224,8 @@ export function DiffLayoutToggle({
|
|||||||
onToggle,
|
onToggle,
|
||||||
}: DiffLayoutToggleProps) {
|
}: DiffLayoutToggleProps) {
|
||||||
const defaultToggleStyle = useMemo(
|
const defaultToggleStyle = useMemo(
|
||||||
() => buildToggleButtonStyle(false, styles.toolbarIconButton),
|
() => buildToggleButtonStyle(false, undefined, isMobile),
|
||||||
[],
|
[isMobile],
|
||||||
);
|
);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const label =
|
const label =
|
||||||
@@ -316,39 +324,6 @@ export function DiffModeMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopTreeToggleProps {
|
|
||||||
visible: boolean;
|
|
||||||
toggleStyle?: PressableStyleFn;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopTreeToggle({ visible, toggleStyle, onToggle }: DesktopTreeToggleProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const defaultToggleStyle = useMemo(
|
|
||||||
() => buildToggleButtonStyle(visible, styles.toolbarIconButton),
|
|
||||||
[visible],
|
|
||||||
);
|
|
||||||
const label = t(visible ? "workspace.git.diff.hideTreeView" : "workspace.git.diff.showTreeView");
|
|
||||||
return (
|
|
||||||
<Tooltip delayDuration={300}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={label}
|
|
||||||
testID="changes-toggle-tree"
|
|
||||||
style={toggleStyle ?? defaultToggleStyle}
|
|
||||||
onPress={onToggle}
|
|
||||||
>
|
|
||||||
<ThemedFolderTree size={14} uniProps={foregroundMutedIconColorMapping} />
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<Text style={styles.tooltipText}>{label}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChangesToolbarProps {
|
interface ChangesToolbarProps {
|
||||||
branchName: string | null;
|
branchName: string | null;
|
||||||
allFilesCollapsed: boolean;
|
allFilesCollapsed: boolean;
|
||||||
@@ -369,7 +344,6 @@ interface ChangesToolbarProps {
|
|||||||
refreshSupported: boolean;
|
refreshSupported: boolean;
|
||||||
selectedDiffStat: { additions: number; deletions: number } | null;
|
selectedDiffStat: { additions: number; deletions: number } | null;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
treeToggleStyle: PressableStyleFn;
|
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -400,14 +374,13 @@ function ChangesToolbar(props: ChangesToolbarProps) {
|
|||||||
isMobile,
|
isMobile,
|
||||||
selectedDiffStat,
|
selectedDiffStat,
|
||||||
serverId,
|
serverId,
|
||||||
treeToggleStyle,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
onSelectBase,
|
onSelectBase,
|
||||||
onSelectUncommitted,
|
onSelectUncommitted,
|
||||||
onToggleDesktopTree,
|
onToggleDesktopTree,
|
||||||
} = props;
|
} = props;
|
||||||
return (
|
return (
|
||||||
<View style={styles.changesToolbar} testID="changes-header">
|
<PaneContentToolbar style={styles.changesToolbar} testID="changes-header">
|
||||||
<View style={styles.changesToolbarIdentity}>
|
<View style={styles.changesToolbarIdentity}>
|
||||||
<DiffModeMenu
|
<DiffModeMenu
|
||||||
diffMode={diffMode}
|
diffMode={diffMode}
|
||||||
@@ -433,16 +406,16 @@ function ChangesToolbar(props: ChangesToolbarProps) {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.changesToolbarControls}>
|
<View style={styles.changesToolbarControls}>
|
||||||
{!isMobile && hasFiles ? (
|
{!isMobile && hasFiles ? (
|
||||||
<DesktopTreeToggle
|
<TreeRailToggle
|
||||||
visible={desktopTreeVisible}
|
visible={desktopTreeVisible}
|
||||||
toggleStyle={treeToggleStyle}
|
testID="changes-toggle-tree"
|
||||||
onToggle={onToggleDesktopTree}
|
onToggle={onToggleDesktopTree}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
|
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
|
||||||
<ChangesOptionsMenu {...props} />
|
<ChangesOptionsMenu {...props} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</PaneContentToolbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +504,7 @@ function ChangesOptionsMenu({
|
|||||||
style={overflowToggleStyle}
|
style={overflowToggleStyle}
|
||||||
>
|
>
|
||||||
<ThemedMoreHorizontal
|
<ThemedMoreHorizontal
|
||||||
size={isMobile ? 18 : 14}
|
size={paneContentToolbarIconSize(isMobile)}
|
||||||
uniProps={foregroundMutedIconColorMapping}
|
uniProps={foregroundMutedIconColorMapping}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -606,8 +579,6 @@ function ChangesOptionsMenu({
|
|||||||
|
|
||||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||||
|
|
||||||
const EMPTY_PATH_LIST: string[] = [];
|
|
||||||
|
|
||||||
function computeEmptyMessage(
|
function computeEmptyMessage(
|
||||||
hideWhitespace: boolean,
|
hideWhitespace: boolean,
|
||||||
diffMode: "uncommitted" | "base",
|
diffMode: "uncommitted" | "base",
|
||||||
@@ -793,74 +764,32 @@ function buildDiffModeTriggerStyle(): PressableStyleFn {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOverflowButtonStyle(): PressableStyleFn {
|
function buildOverflowButtonStyle(isMobile: boolean): PressableStyleFn {
|
||||||
return ({ hovered, pressed }) => [
|
return (state) => paneContentToolbarIconButtonStyle(state, false, isMobile);
|
||||||
styles.overflowButton,
|
|
||||||
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToggleButtonStyle(
|
function buildToggleButtonStyle(
|
||||||
selected: boolean,
|
selected: boolean,
|
||||||
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
|
baseStyles?: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
|
||||||
|
isMobile = false,
|
||||||
): PressableStyleFn {
|
): PressableStyleFn {
|
||||||
return ({ hovered, pressed }) => [
|
return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)];
|
||||||
baseStyles,
|
|
||||||
(selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCollapsedDiffFolders({
|
|
||||||
workspaceId,
|
|
||||||
cwd,
|
|
||||||
}: {
|
|
||||||
workspaceId?: string | null;
|
|
||||||
cwd: string;
|
|
||||||
}) {
|
|
||||||
const workspaceStateKey = useMemo(
|
|
||||||
() =>
|
|
||||||
buildWorkspaceExplorerStateKey({
|
|
||||||
workspaceId,
|
|
||||||
workspaceRoot: cwd.trim(),
|
|
||||||
}),
|
|
||||||
[cwd, workspaceId],
|
|
||||||
);
|
|
||||||
const collapsedFolders = usePanelStore((state) =>
|
|
||||||
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
|
|
||||||
);
|
|
||||||
const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace);
|
|
||||||
const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST;
|
|
||||||
const updateCollapsedFolders = useCallback(
|
|
||||||
(paths: string[]) => {
|
|
||||||
if (workspaceStateKey) {
|
|
||||||
setCollapsedFolders(workspaceStateKey, paths);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setCollapsedFolders, workspaceStateKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
collapsedFolders: stableCollapsedFolders,
|
|
||||||
updateCollapsedFolders,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChangedFilesTree({
|
function ChangedFilesTree({
|
||||||
workspaceId,
|
|
||||||
cwd,
|
|
||||||
files,
|
files,
|
||||||
mode,
|
mode,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
|
collapsedFolderPaths,
|
||||||
|
onCollapsedFolderPathsChange,
|
||||||
}: {
|
}: {
|
||||||
workspaceId?: string | null;
|
|
||||||
cwd: string;
|
|
||||||
files: ParsedDiffFile[];
|
files: ParsedDiffFile[];
|
||||||
mode: WorkingDiffMode;
|
mode: WorkingDiffMode;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
|
collapsedFolderPaths: string[];
|
||||||
|
onCollapsedFolderPathsChange: (paths: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
const { collapsedFolders: collapsedFolderPaths, updateCollapsedFolders } =
|
|
||||||
useCollapsedDiffFolders({ workspaceId, cwd });
|
|
||||||
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
||||||
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
||||||
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
|
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
|
||||||
@@ -881,14 +810,14 @@ function ChangedFilesTree({
|
|||||||
const next = collapsedFolders.has(dirPath)
|
const next = collapsedFolders.has(dirPath)
|
||||||
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
|
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
|
||||||
: [...collapsedFolders, dirPath];
|
: [...collapsedFolders, dirPath];
|
||||||
updateCollapsedFolders(next);
|
onCollapsedFolderPathsChange(next);
|
||||||
},
|
},
|
||||||
[collapsedFolders, updateCollapsedFolders],
|
[collapsedFolders, onCollapsedFolderPathsChange],
|
||||||
);
|
);
|
||||||
const handleCollapseFolder = useCallback(
|
const handleCollapseFolder = useCallback(
|
||||||
(dirPath: string) => {
|
(dirPath: string) => {
|
||||||
const prefix = `${dirPath}/`;
|
const prefix = `${dirPath}/`;
|
||||||
updateCollapsedFolders([
|
onCollapsedFolderPathsChange([
|
||||||
...new Set([
|
...new Set([
|
||||||
...collapsedFolders,
|
...collapsedFolders,
|
||||||
...allFolderPaths.filter(
|
...allFolderPaths.filter(
|
||||||
@@ -897,7 +826,7 @@ function ChangedFilesTree({
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
[allFolderPaths, collapsedFolders, updateCollapsedFolders],
|
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
|
||||||
);
|
);
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: DiffTreeRow }) => {
|
({ item }: { item: DiffTreeRow }) => {
|
||||||
@@ -979,30 +908,34 @@ function ChangedFilesTree({
|
|||||||
function ChangesTreeRail({
|
function ChangesTreeRail({
|
||||||
shown,
|
shown,
|
||||||
children,
|
children,
|
||||||
workspaceId,
|
|
||||||
cwd,
|
|
||||||
files,
|
files,
|
||||||
mode,
|
mode,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
|
treeWidth,
|
||||||
|
onTreeWidthChange,
|
||||||
|
collapsedFolderPaths,
|
||||||
|
onCollapsedFolderPathsChange,
|
||||||
}: {
|
}: {
|
||||||
shown: boolean;
|
shown: boolean;
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
workspaceId?: string | null;
|
|
||||||
cwd: string;
|
|
||||||
files: ParsedDiffFile[];
|
files: ParsedDiffFile[];
|
||||||
mode: WorkingDiffMode;
|
mode: WorkingDiffMode;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
|
treeWidth?: number;
|
||||||
|
onTreeWidthChange: (width: number) => void;
|
||||||
|
collapsedFolderPaths: string[];
|
||||||
|
onCollapsedFolderPathsChange: (paths: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!shown) return children;
|
if (!shown) return children;
|
||||||
return (
|
return (
|
||||||
<TreeRail testID="changes-tree-rail">
|
<TreeRail testID="changes-tree-rail" width={treeWidth ?? 220} onWidthChange={onTreeWidthChange}>
|
||||||
{children}
|
{children}
|
||||||
<ChangedFilesTree
|
<ChangedFilesTree
|
||||||
workspaceId={workspaceId}
|
|
||||||
cwd={cwd}
|
|
||||||
files={files}
|
files={files}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
collapsedFolderPaths={collapsedFolderPaths}
|
||||||
|
onCollapsedFolderPathsChange={onCollapsedFolderPathsChange}
|
||||||
/>
|
/>
|
||||||
</TreeRail>
|
</TreeRail>
|
||||||
);
|
);
|
||||||
@@ -1019,33 +952,30 @@ function useDiffTabNavigation({
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
|
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||||
(state) => state.openTabInFocusedPane,
|
const openWorkspaceTabInFocusedPane = useCallback(
|
||||||
|
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
|
||||||
|
openTab({ workspaceKey, target, intent: "reveal", placement }),
|
||||||
|
[openTab],
|
||||||
);
|
);
|
||||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
|
||||||
const persistenceKey = useMemo(
|
const persistenceKey = useMemo(
|
||||||
() => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }),
|
() => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }),
|
||||||
[cwd, serverId, workspaceId],
|
[cwd, serverId, workspaceId],
|
||||||
);
|
);
|
||||||
const changesTabId = useWorkspaceLayoutStore((state) => {
|
const changesTabOpen = false;
|
||||||
if (!persistenceKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const layout = state.layoutByWorkspace[persistenceKey];
|
|
||||||
return (
|
|
||||||
layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const changesTabOpen = !isMobile && Boolean(changesTabId);
|
|
||||||
const openChanges = useCallback(
|
const openChanges = useCallback(
|
||||||
(path?: string) => {
|
(path?: string) => {
|
||||||
if (!persistenceKey || isMobile) {
|
if (!persistenceKey || isMobile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openWorkspaceTabInFocusedPane(persistenceKey, {
|
openWorkspaceTabInFocusedPane(
|
||||||
kind: "working_diff",
|
persistenceKey,
|
||||||
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
|
{
|
||||||
});
|
kind: "working_diff",
|
||||||
|
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
|
||||||
|
},
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[isMobile, openWorkspaceTabInFocusedPane, persistenceKey],
|
[isMobile, openWorkspaceTabInFocusedPane, persistenceKey],
|
||||||
);
|
);
|
||||||
@@ -1053,16 +983,16 @@ function useDiffTabNavigation({
|
|||||||
if (!persistenceKey || isMobile) {
|
if (!persistenceKey || isMobile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (changesTabId) {
|
|
||||||
closeWorkspaceTab(persistenceKey, changesTabId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openChanges();
|
openChanges();
|
||||||
}, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]);
|
}, [isMobile, openChanges, persistenceKey]);
|
||||||
const openCommit = useCallback(
|
const openCommit = useCallback(
|
||||||
(sha: string) => {
|
(sha: string) => {
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "commit_diff", sha });
|
openWorkspaceTabInFocusedPane(
|
||||||
|
persistenceKey,
|
||||||
|
{ kind: "commit_diff", sha },
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[openWorkspaceTabInFocusedPane, persistenceKey],
|
[openWorkspaceTabInFocusedPane, persistenceKey],
|
||||||
@@ -1086,27 +1016,26 @@ export function ChangesSurface({
|
|||||||
focusRequestId,
|
focusRequestId,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
|
state: changesState,
|
||||||
|
onStateChange,
|
||||||
}: ChangesSurfaceProps) {
|
}: ChangesSurfaceProps) {
|
||||||
const { settings: appSettings } = useAppSettings();
|
const { settings: appSettings } = useAppSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const canUseSplitLayout = isWeb && !isMobile;
|
const canUseSplitLayout = isWeb && !isMobile;
|
||||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
const instanceState = changesState ?? defaultChangesState;
|
||||||
useChangesPreferences();
|
const updateState = onStateChange ?? noopStateChange;
|
||||||
const wrapLines = changesPreferences.wrapLines;
|
const wrapLines = instanceState.wrapLines;
|
||||||
const desktopTreeVisible = changesPreferences.desktopTreeVisible;
|
const desktopTreeVisible = instanceState.treeVisible;
|
||||||
const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout);
|
const effectiveLayout = resolveDiffLayout(instanceState.layout, canUseSplitLayout);
|
||||||
const collapsedFilesWorkspaceKey = `${serverId}:${workspaceId ?? cwd}`;
|
const collapsedFilePaths = instanceState.collapsedFilePaths;
|
||||||
const persistedCollapsedFilePaths = usePanelStore(
|
|
||||||
(state) => state.collapsedFilePathsByWorkspace[collapsedFilesWorkspaceKey],
|
|
||||||
);
|
|
||||||
const setCollapsedFilePaths = usePanelStore((state) => state.setCollapsedFilePathsForWorkspace);
|
|
||||||
const collapsedFilePaths = persistedCollapsedFilePaths ?? EMPTY_PATH_LIST;
|
|
||||||
const updateCollapsedFilePaths = useCallback(
|
const updateCollapsedFilePaths = useCallback(
|
||||||
(paths: string[]) => {
|
(paths: string[]) => updateState({ ...instanceState, collapsedFilePaths: paths }),
|
||||||
setCollapsedFilePaths(collapsedFilesWorkspaceKey, paths);
|
[instanceState, updateState],
|
||||||
},
|
);
|
||||||
[collapsedFilesWorkspaceKey, setCollapsedFilePaths],
|
const updateCollapsedFolderPaths = useCallback(
|
||||||
|
(paths: string[]) => updateState({ ...instanceState, collapsedFolderPaths: paths }),
|
||||||
|
[instanceState, updateState],
|
||||||
);
|
);
|
||||||
const collapseState = useMemo(
|
const collapseState = useMemo(
|
||||||
() => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }),
|
() => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }),
|
||||||
@@ -1114,26 +1043,30 @@ export function ChangesSurface({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleWrapLines = useCallback(() => {
|
const handleToggleWrapLines = useCallback(() => {
|
||||||
void updateChangesPreferences({ wrapLines: !wrapLines });
|
updateState({ ...instanceState, wrapLines: !wrapLines });
|
||||||
}, [updateChangesPreferences, wrapLines]);
|
}, [instanceState, updateState, wrapLines]);
|
||||||
|
|
||||||
const handleToggleHideWhitespace = useCallback(() => {
|
const handleToggleHideWhitespace = useCallback(() => {
|
||||||
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
updateState({
|
||||||
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
...instanceState,
|
||||||
|
hideWhitespace: !instanceState.hideWhitespace,
|
||||||
|
});
|
||||||
|
}, [instanceState, updateState]);
|
||||||
|
|
||||||
const handleToggleLayout = useCallback(() => {
|
const handleToggleLayout = useCallback(() => {
|
||||||
void updateChangesPreferences({
|
updateState({
|
||||||
layout: changesPreferences.layout === "unified" ? "split" : "unified",
|
...instanceState,
|
||||||
|
layout: instanceState.layout === "unified" ? "split" : "unified",
|
||||||
});
|
});
|
||||||
}, [changesPreferences.layout, updateChangesPreferences]);
|
}, [instanceState, updateState]);
|
||||||
|
const handleDiffModeChange = useCallback(
|
||||||
const codeFontSize = appSettings.codeFontSize;
|
(mode: "uncommitted" | "base") => updateState({ ...instanceState, mode }),
|
||||||
const treeToggleStyle = useMemo(
|
[instanceState, updateState],
|
||||||
() => buildToggleButtonStyle(desktopTreeVisible, styles.toolbarIconButton),
|
|
||||||
[desktopTreeVisible],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
|
const codeFontSize = appSettings.codeFontSize;
|
||||||
|
|
||||||
|
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(isMobile), [isMobile]);
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
@@ -1192,8 +1125,11 @@ export function ChangesSurface({
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId: workspaceId ?? undefined,
|
workspaceId: workspaceId ?? undefined,
|
||||||
cwd,
|
cwd,
|
||||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
ignoreWhitespace: instanceState.hideWhitespace,
|
||||||
enabled: enabled !== false,
|
enabled: enabled !== false,
|
||||||
|
mode: instanceState.mode,
|
||||||
|
baseRef: instanceState.baseRef,
|
||||||
|
onModeChange: handleDiffModeChange,
|
||||||
});
|
});
|
||||||
usePublishWorkingDiffAttachment({
|
usePublishWorkingDiffAttachment({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1231,8 +1167,16 @@ export function ChangesSurface({
|
|||||||
[forgeSetupAction, forge, status?.remoteUrl, t],
|
[forgeSetupAction, forge, status?.remoteUrl, t],
|
||||||
);
|
);
|
||||||
const handleToggleDesktopTree = useCallback(() => {
|
const handleToggleDesktopTree = useCallback(() => {
|
||||||
void updateChangesPreferences({ desktopTreeVisible: !desktopTreeVisible });
|
updateState({ ...instanceState, treeVisible: !desktopTreeVisible });
|
||||||
}, [desktopTreeVisible, updateChangesPreferences]);
|
}, [desktopTreeVisible, instanceState, updateState]);
|
||||||
|
const handleCommitsCollapsedChange = useCallback(
|
||||||
|
(commitsCollapsed: boolean) => updateState({ ...instanceState, commitsCollapsed }),
|
||||||
|
[instanceState, updateState],
|
||||||
|
);
|
||||||
|
const handleChangesTreeWidth = useCallback(
|
||||||
|
(treeWidth: number) => updateState({ ...instanceState, treeWidth }),
|
||||||
|
[instanceState, updateState],
|
||||||
|
);
|
||||||
const sharedDisplayPreferences = useMemo(
|
const sharedDisplayPreferences = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
layout: effectiveLayout,
|
layout: effectiveLayout,
|
||||||
@@ -1383,16 +1327,11 @@ export function ChangesSurface({
|
|||||||
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
||||||
[baseRefLabel, branchLabel],
|
[baseRefLabel, branchLabel],
|
||||||
);
|
);
|
||||||
const emptyMessage = computeEmptyMessage(
|
const emptyMessage = computeEmptyMessage(instanceState.hideWhitespace, diffMode, baseRefLabel, {
|
||||||
changesPreferences.hideWhitespace,
|
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
|
||||||
diffMode,
|
uncommitted: t("workspace.git.diff.emptyUncommitted"),
|
||||||
baseRefLabel,
|
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
|
||||||
{
|
});
|
||||||
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
|
|
||||||
uncommitted: t("workspace.git.diff.emptyUncommitted"),
|
|
||||||
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const diffContent: ReactElement = (
|
const diffContent: ReactElement = (
|
||||||
<DiffBodyContent
|
<DiffBodyContent
|
||||||
@@ -1418,11 +1357,13 @@ export function ChangesSurface({
|
|||||||
const bodyContent = (
|
const bodyContent = (
|
||||||
<ChangesTreeRail
|
<ChangesTreeRail
|
||||||
shown={desktopTreeVisible && !isMobile && files.length > 0}
|
shown={desktopTreeVisible && !isMobile && files.length > 0}
|
||||||
workspaceId={workspaceId}
|
|
||||||
cwd={cwd}
|
|
||||||
files={files}
|
files={files}
|
||||||
mode={workingMode}
|
mode={workingMode}
|
||||||
onSelectFile={handleSelectTreeFile}
|
onSelectFile={handleSelectTreeFile}
|
||||||
|
treeWidth={instanceState.treeWidth}
|
||||||
|
onTreeWidthChange={handleChangesTreeWidth}
|
||||||
|
collapsedFolderPaths={instanceState.collapsedFolderPaths}
|
||||||
|
onCollapsedFolderPathsChange={updateCollapsedFolderPaths}
|
||||||
>
|
>
|
||||||
{diffContent}
|
{diffContent}
|
||||||
</ChangesTreeRail>
|
</ChangesTreeRail>
|
||||||
@@ -1447,16 +1388,15 @@ export function ChangesSurface({
|
|||||||
diffMode={diffMode}
|
diffMode={diffMode}
|
||||||
gitActions={gitActions}
|
gitActions={gitActions}
|
||||||
hasFiles={hasChanges}
|
hasFiles={hasChanges}
|
||||||
hideWhitespace={changesPreferences.hideWhitespace}
|
hideWhitespace={instanceState.hideWhitespace}
|
||||||
host={host}
|
host={host}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
layout={changesPreferences.layout}
|
layout={instanceState.layout}
|
||||||
overflowToggleStyle={overflowToggleStyle}
|
overflowToggleStyle={overflowToggleStyle}
|
||||||
refreshSupported={refreshSupported}
|
refreshSupported={refreshSupported}
|
||||||
selectedDiffStat={selectedDiffStat}
|
selectedDiffStat={selectedDiffStat}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
treeToggleStyle={treeToggleStyle}
|
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
onCollapseAll={handleCollapseAllFiles}
|
onCollapseAll={handleCollapseAllFiles}
|
||||||
@@ -1482,7 +1422,13 @@ export function ChangesSurface({
|
|||||||
|
|
||||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||||
|
|
||||||
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
|
<CommitsSection
|
||||||
|
serverId={serverId}
|
||||||
|
cwd={cwd}
|
||||||
|
onCommitPress={handleCommitPress}
|
||||||
|
collapsed={instanceState.commitsCollapsed}
|
||||||
|
onCollapsedChange={handleCommitsCollapsedChange}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1493,16 +1439,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
changesToolbar: {
|
changesToolbar: {
|
||||||
minHeight: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingLeft: theme.spacing[2],
|
paddingLeft: theme.spacing[2],
|
||||||
paddingRight: theme.spacing[3],
|
paddingRight: theme.spacing[3],
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
},
|
||||||
changesToolbarIdentity: {
|
changesToolbarIdentity: {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -1535,12 +1477,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffModeTriggerHovered: {
|
diffModeTriggerHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
diffModeTriggerPressed: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
diffStatusRowHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
diffStatusText: {
|
diffStatusText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
lineHeight: theme.fontSize.sm * 1.25,
|
lineHeight: theme.fontSize.sm * 1.25,
|
||||||
@@ -1549,44 +1485,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffStatusIconHidden: {
|
diffStatusIconHidden: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
toggleButtonSelected: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
toolbarIconButton: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
minWidth: {
|
|
||||||
xs: 32,
|
|
||||||
sm: 32,
|
|
||||||
md: 24,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
xs: 32,
|
|
||||||
sm: 32,
|
|
||||||
md: 24,
|
|
||||||
},
|
|
||||||
paddingHorizontal: {
|
|
||||||
xs: theme.spacing[2],
|
|
||||||
sm: theme.spacing[2],
|
|
||||||
md: theme.spacing[1],
|
|
||||||
},
|
|
||||||
borderRadius: theme.borderRadius.base,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
overflowButton: {
|
|
||||||
width: ICON_SIZE.sm + 2 * SPACING[1],
|
|
||||||
height: {
|
|
||||||
xs: 32,
|
|
||||||
sm: 32,
|
|
||||||
md: 24,
|
|
||||||
},
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: theme.borderRadius.base,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
actionErrorText: {
|
actionErrorText: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[1],
|
paddingBottom: theme.spacing[1],
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { CheckStatus } from "./check-status";
|
||||||
|
|
||||||
|
/** Per-mounted PR panel state; never shared with another tab instance. */
|
||||||
|
export function useCheckGroupState() {
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
|
||||||
|
const toggle = useCallback((status: CheckStatus) => {
|
||||||
|
setCollapsedGroups((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (!next.delete(status)) next.add(status);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return { collapsedGroups, toggle };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
||||||
@@ -11,6 +11,7 @@ import { summarizeChecks, type ChecksGroup } from "./checks-summary";
|
|||||||
import { canAddPullRequestCheckLogsToChat } from "./context-attachment";
|
import { canAddPullRequestCheckLogsToChat } from "./context-attachment";
|
||||||
import type { PrPaneCheck } from "./data";
|
import type { PrPaneCheck } from "./data";
|
||||||
import { CheckStatusIcon, foregroundMutedColorMapping, sectionKitStyles } from "./section-kit";
|
import { CheckStatusIcon, foregroundMutedColorMapping, sectionKitStyles } from "./section-kit";
|
||||||
|
import { useCheckGroupState } from "./check-group-state";
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||||
@@ -67,17 +68,7 @@ export function ChecksSection({
|
|||||||
onAddLogsToChat,
|
onAddLogsToChat,
|
||||||
}: ChecksSectionProps) {
|
}: ChecksSectionProps) {
|
||||||
const summary = useMemo(() => summarizeChecks(checks), [checks]);
|
const summary = useMemo(() => summarizeChecks(checks), [checks]);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
|
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
|
||||||
|
|
||||||
const handleToggleGroup = useCallback((status: CheckStatus) => {
|
|
||||||
setCollapsedGroups((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (!next.delete(status)) {
|
|
||||||
next.add(status);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID="pr-pane-checks">
|
<View testID="pr-pane-checks">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
|
||||||
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
|
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
|
||||||
|
|
||||||
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
|
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
|
||||||
@@ -16,10 +17,10 @@ export function PullRequestPaneSkeleton() {
|
|||||||
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
|
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.toolbar}>
|
<PaneContentToolbar style={styles.toolbar}>
|
||||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||||
</View>
|
</PaneContentToolbar>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
|
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||||
@@ -68,9 +69,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
},
|
||||||
toolbarButton: {
|
toolbarButton: {
|
||||||
width: 96,
|
width: 96,
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachment
|
|||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import {
|
||||||
|
PaneContentToolbar,
|
||||||
|
paneContentToolbarIconSize,
|
||||||
|
paneContentToolbarIconButtonStyle,
|
||||||
|
} from "@/components/ui/pane-content-toolbar";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import { getForgePresentation } from "@/git/forge";
|
import { getForgePresentation } from "@/git/forge";
|
||||||
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
|
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
|
||||||
@@ -145,11 +150,11 @@ function kebabTriggerStyle({
|
|||||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshButtonStyle({
|
function refreshButtonStyle(
|
||||||
hovered = false,
|
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||||
pressed = false,
|
isCompact: boolean,
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
) {
|
||||||
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
|
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
@@ -193,6 +198,11 @@ export function PullRequestPane({
|
|||||||
workspaceAttachmentScopeKey?: string;
|
workspaceAttachmentScopeKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const toolbarRefreshButtonStyle = useCallback(
|
||||||
|
(state: PressableStateCallbackType) => refreshButtonStyle(state, isCompact),
|
||||||
|
[isCompact],
|
||||||
|
);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const daemonClient = useHostRuntimeClient(serverId);
|
const daemonClient = useHostRuntimeClient(serverId);
|
||||||
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
@@ -467,7 +477,7 @@ export function PullRequestPane({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.root} testID="pr-pane">
|
<View style={styles.root} testID="pr-pane">
|
||||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||||
<View style={styles.toolbar} testID="pr-pane-toolbar">
|
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
|
||||||
<View style={styles.toolbarActions}>
|
<View style={styles.toolbarActions}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -489,7 +499,7 @@ export function PullRequestPane({
|
|||||||
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
|
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
|
||||||
}
|
}
|
||||||
testID="pr-pane-refresh"
|
testID="pr-pane-refresh"
|
||||||
style={refreshButtonStyle}
|
style={toolbarRefreshButtonStyle}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
onPress={handleRefresh}
|
onPress={handleRefresh}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
@@ -497,16 +507,19 @@ export function PullRequestPane({
|
|||||||
<View style={styles.refreshIcon}>
|
<View style={styles.refreshIcon}>
|
||||||
{isRefreshing ? (
|
{isRefreshing ? (
|
||||||
<ThemedLoadingSpinner
|
<ThemedLoadingSpinner
|
||||||
size={ICON_SIZE.sm}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
uniProps={foregroundMutedColorMapping}
|
uniProps={foregroundMutedColorMapping}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
<ThemedRotateCw
|
||||||
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
|
uniProps={foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</PaneContentToolbar>
|
||||||
|
|
||||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
@@ -1239,15 +1252,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
paddingTop: theme.spacing[2],
|
|
||||||
paddingRight: theme.spacing[3],
|
paddingRight: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[2],
|
|
||||||
paddingLeft: theme.spacing[3],
|
paddingLeft: theme.spacing[3],
|
||||||
},
|
},
|
||||||
toolbarActions: {
|
toolbarActions: {
|
||||||
@@ -1263,17 +1271,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.base,
|
borderRadius: theme.borderRadius.base,
|
||||||
},
|
},
|
||||||
refreshButton: {
|
|
||||||
marginLeft: "auto",
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
refreshButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
refreshIcon: {
|
refreshIcon: {
|
||||||
width: ICON_SIZE.md,
|
width: ICON_SIZE.md,
|
||||||
height: ICON_SIZE.md,
|
height: ICON_SIZE.md,
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import {
|
|||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import {
|
import {
|
||||||
buildReviewDraftKey,
|
buildReviewDraftKey,
|
||||||
buildReviewDraftScopeKey,
|
|
||||||
useInlineReviewController,
|
useInlineReviewController,
|
||||||
useResolvedDiffMode,
|
|
||||||
useReviewAttachmentSnapshot,
|
useReviewAttachmentSnapshot,
|
||||||
useSetDiffModeOverride,
|
|
||||||
} from "@/review";
|
} from "@/review";
|
||||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
@@ -21,6 +18,9 @@ interface UseWorkingDiffOptions {
|
|||||||
ignoreWhitespace: boolean;
|
ignoreWhitespace: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
queryScope?: string;
|
queryScope?: string;
|
||||||
|
mode?: "uncommitted" | "base";
|
||||||
|
baseRef?: string;
|
||||||
|
onModeChange?: (mode: "uncommitted" | "base") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkingDiff({
|
export function useWorkingDiff({
|
||||||
@@ -30,6 +30,9 @@ export function useWorkingDiff({
|
|||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
enabled,
|
enabled,
|
||||||
queryScope,
|
queryScope,
|
||||||
|
mode,
|
||||||
|
baseRef: selectedBaseRef,
|
||||||
|
onModeChange,
|
||||||
}: UseWorkingDiffOptions) {
|
}: UseWorkingDiffOptions) {
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
@@ -43,38 +46,26 @@ export function useWorkingDiff({
|
|||||||
const statusErrorMessage =
|
const statusErrorMessage =
|
||||||
status?.error?.message ??
|
status?.error?.message ??
|
||||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
const baseRef = selectedBaseRef ?? gitStatus?.baseRef ?? undefined;
|
||||||
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||||
const currentBranchName =
|
const currentBranchName =
|
||||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||||
|
|
||||||
const reviewDraftScopeKey = useMemo(
|
|
||||||
() =>
|
|
||||||
buildReviewDraftScopeKey({
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
cwd,
|
|
||||||
baseRef,
|
|
||||||
ignoreWhitespace,
|
|
||||||
}),
|
|
||||||
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
|
|
||||||
);
|
|
||||||
const diffMode = useResolvedDiffMode({
|
|
||||||
scopeKey: reviewDraftScopeKey,
|
|
||||||
hasUncommittedChanges,
|
|
||||||
});
|
|
||||||
const setDiffModeOverride = useSetDiffModeOverride();
|
|
||||||
const selectDiffMode = useCallback(
|
const selectDiffMode = useCallback(
|
||||||
(mode: "uncommitted" | "base") => {
|
(nextMode: "uncommitted" | "base") => {
|
||||||
setDiffModeOverride({
|
if (onModeChange) {
|
||||||
scopeKey: reviewDraftScopeKey,
|
onModeChange(nextMode);
|
||||||
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
|
return;
|
||||||
});
|
}
|
||||||
|
// A Changes surface always supplies its tab-owned presentation. Keep this
|
||||||
|
// fallback deterministic for legacy non-tab consumers without mutating
|
||||||
|
// workspace-wide review preferences.
|
||||||
},
|
},
|
||||||
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
|
[onModeChange],
|
||||||
);
|
);
|
||||||
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
|
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
|
||||||
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
|
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
|
||||||
|
const diffMode = mode ?? (hasUncommittedChanges ? "uncommitted" : "base");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export interface AppSettings {
|
|||||||
toolCallDetailLevel: ToolCallDetailLevel;
|
toolCallDetailLevel: ToolCallDetailLevel;
|
||||||
chatOutlineEnabled: boolean;
|
chatOutlineEnabled: boolean;
|
||||||
vimKeybindings: boolean;
|
vimKeybindings: boolean;
|
||||||
|
/** Route implicitly opened supporting tabs into the Side panel. Desktop only. */
|
||||||
|
openSupportingTabsInSidePanel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings extends AppSettings {
|
export interface Settings extends AppSettings {
|
||||||
@@ -124,6 +126,7 @@ const StoredAppSettingsSchema = z.strictObject({
|
|||||||
compactToolCalls: z.boolean().optional(),
|
compactToolCalls: z.boolean().optional(),
|
||||||
chatOutlineEnabled: z.boolean().optional(),
|
chatOutlineEnabled: z.boolean().optional(),
|
||||||
vimKeybindings: z.boolean().optional(),
|
vimKeybindings: z.boolean().optional(),
|
||||||
|
openSupportingTabsInSidePanel: z.boolean().optional(),
|
||||||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||||||
manageBuiltInDaemon: z.boolean().optional(),
|
manageBuiltInDaemon: z.boolean().optional(),
|
||||||
releaseChannel: z.enum(["stable", "beta"]).optional(),
|
releaseChannel: z.enum(["stable", "beta"]).optional(),
|
||||||
@@ -153,6 +156,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
|||||||
toolCallDetailLevel: "detailed",
|
toolCallDetailLevel: "detailed",
|
||||||
chatOutlineEnabled: true,
|
chatOutlineEnabled: true,
|
||||||
vimKeybindings: false,
|
vimKeybindings: false,
|
||||||
|
openSupportingTabsInSidePanel: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||||
@@ -315,6 +319,9 @@ function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings>
|
|||||||
if (typeof stored.chatOutlineEnabled === "boolean") {
|
if (typeof stored.chatOutlineEnabled === "boolean") {
|
||||||
result.chatOutlineEnabled = stored.chatOutlineEnabled;
|
result.chatOutlineEnabled = stored.chatOutlineEnabled;
|
||||||
}
|
}
|
||||||
|
if (typeof stored.openSupportingTabsInSidePanel === "boolean") {
|
||||||
|
result.openSupportingTabsInSidePanel = stored.openSupportingTabsInSidePanel;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -412,7 +412,6 @@ describe("translation resources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes workspace tab toast keys for the Batch 4J migration", () => {
|
it("includes workspace tab toast keys for the Batch 4J migration", () => {
|
||||||
expect(en.workspace.tabs.emptyPane).toBe("No tabs in this pane.");
|
|
||||||
expect(en.workspace.tabs.toasts.copyFailed).toBe("Copy failed");
|
expect(en.workspace.tabs.toasts.copyFailed).toBe("Copy failed");
|
||||||
expect(en.workspace.tabs.toasts.agentIdCopiedLabel).toBe("Agent ID");
|
expect(en.workspace.tabs.toasts.agentIdCopiedLabel).toBe("Agent ID");
|
||||||
expect(en.workspace.tabs.toasts.resumeCommandCopiedLabel).toBe("resume command");
|
expect(en.workspace.tabs.toasts.resumeCommandCopiedLabel).toBe("resume command");
|
||||||
@@ -494,12 +493,12 @@ describe("translation resources", () => {
|
|||||||
expect(en.agentList.dateSections.recent).toBe("Recent");
|
expect(en.agentList.dateSections.recent).toBe("Recent");
|
||||||
expect(en.message.attachments.imagePreviewUnavailable).toBe("Image preview unavailable.");
|
expect(en.message.attachments.imagePreviewUnavailable).toBe("Image preview unavailable.");
|
||||||
expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview.");
|
expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview.");
|
||||||
expect(en.workspace.tabs.explorer.changes).toBe("Changes");
|
expect(en.workspace.tabs.sidePanel.changes).toBe("Changes");
|
||||||
expect(en.workspace.tabs.explorer.files).toBe("Files");
|
expect(en.workspace.tabs.sidePanel.files).toBe("Files");
|
||||||
expect(en.workspace.tabs.actions.maximizePane).toBe("Maximize pane");
|
expect(en.workspace.tabs.actions.maximizePane).toBe("Maximize pane");
|
||||||
expect(en.workspace.tabs.actions.restorePane).toBe("Restore pane");
|
expect(en.workspace.tabs.actions.restorePane).toBe("Restore pane");
|
||||||
expect(en.settings.shortcuts.help.toggleExplorerPaneMaximization).toBe(
|
expect(en.settings.shortcuts.help.toggleExplorerPaneMaximization).toBe(
|
||||||
"Toggle Explorer pane maximization",
|
"Toggle Side panel maximization",
|
||||||
);
|
);
|
||||||
expect(en.branchSwitcher.triggerTooltip).toBe("Switch workspace branch");
|
expect(en.branchSwitcher.triggerTooltip).toBe("Switch workspace branch");
|
||||||
expect(en.branchSwitcher.uncommittedTitle).toBe("Uncommitted changes");
|
expect(en.branchSwitcher.uncommittedTitle).toBe("Uncommitted changes");
|
||||||
|
|||||||
@@ -553,7 +553,6 @@ export const ar: TranslationResources = {
|
|||||||
loading: "تحميل...",
|
loading: "تحميل...",
|
||||||
modified: "تغييرات غير محفوظة",
|
modified: "تغييرات غير محفوظة",
|
||||||
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
|
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
|
||||||
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "وكيل جديد",
|
newAgent: "وكيل جديد",
|
||||||
setup: "يثبت",
|
setup: "يثبت",
|
||||||
@@ -595,6 +594,7 @@ export const ar: TranslationResources = {
|
|||||||
newBrowser: "متصفح جديد",
|
newBrowser: "متصفح جديد",
|
||||||
maximizePane: "تكبير الجزء",
|
maximizePane: "تكبير الجزء",
|
||||||
restorePane: "استعادة الجزء",
|
restorePane: "استعادة الجزء",
|
||||||
|
closePane: "إغلاق الجزء",
|
||||||
exitFocusMode: "إنهاء وضع التركيز",
|
exitFocusMode: "إنهاء وضع التركيز",
|
||||||
splitRight: "تقسيم الجزء الأيمن",
|
splitRight: "تقسيم الجزء الأيمن",
|
||||||
splitDown: "تقسيم الجزء لأسفل",
|
splitDown: "تقسيم الجزء لأسفل",
|
||||||
@@ -604,10 +604,10 @@ export const ar: TranslationResources = {
|
|||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "افتح المستكشف",
|
open: "افتح اللوحة الجانبية",
|
||||||
close: "إغلاق المستكشف",
|
close: "إغلاق اللوحة الجانبية",
|
||||||
toggle: "تبديل المستكشف",
|
toggle: "تبديل اللوحة الجانبية",
|
||||||
changes: "التغييرات",
|
changes: "التغييرات",
|
||||||
files: "ملفات",
|
files: "ملفات",
|
||||||
},
|
},
|
||||||
@@ -710,6 +710,10 @@ export const ar: TranslationResources = {
|
|||||||
stopFailed: "فشل إيقاف{{scriptName}}",
|
stopFailed: "فشل إيقاف{{scriptName}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "إظهار شجرة المجلدات",
|
||||||
|
hideFolderTree: "إخفاء شجرة المجلدات",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "المزيد من الخيارات",
|
moreOptions: "المزيد من الخيارات",
|
||||||
@@ -855,8 +859,6 @@ export const ar: TranslationResources = {
|
|||||||
split: "فرق جنبًا إلى جنب",
|
split: "فرق جنبًا إلى جنب",
|
||||||
switchToUnified: "التبديل إلى الفرق الموحد",
|
switchToUnified: "التبديل إلى الفرق الموحد",
|
||||||
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
|
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
|
||||||
showTreeView: "إظهار شجرة المجلدات",
|
|
||||||
hideTreeView: "إخفاء شجرة المجلدات",
|
|
||||||
options: "خيارات الفرق",
|
options: "خيارات الفرق",
|
||||||
hideWhitespace: "إخفاء المسافة البيضاء",
|
hideWhitespace: "إخفاء المسافة البيضاء",
|
||||||
showWhitespace: "إظهار المسافة البيضاء",
|
showWhitespace: "إظهار المسافة البيضاء",
|
||||||
@@ -1917,6 +1919,11 @@ export const ar: TranslationResources = {
|
|||||||
description: "يتم الاحتفاظ بالخطوط في المخزن المؤقت الطرفي المدمج",
|
description: "يتم الاحتفاظ بالخطوط في المخزن المؤقت الطرفي المدمج",
|
||||||
accessibilityLabel: "خطوط التمرير Terminal",
|
accessibilityLabel: "خطوط التمرير Terminal",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "فتح علامات التبويب المساعدة في اللوحة الجانبية",
|
||||||
|
description:
|
||||||
|
"تفتح روابط الملفات وطلبات السحب وتقدم الإعداد بجوار عملك بدلاً من الجزء المركّز عليه",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "عرض التفكير دائماً",
|
label: "عرض التفكير دائماً",
|
||||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||||
@@ -2100,11 +2107,11 @@ export const ar: TranslationResources = {
|
|||||||
toggleCommandCenter: "تبديل مركز القيادة",
|
toggleCommandCenter: "تبديل مركز القيادة",
|
||||||
showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح",
|
showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح",
|
||||||
toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر",
|
toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر",
|
||||||
toggleRightSidebar: "تبديل الشريط الجانبي الأيمن",
|
toggleRightSidebar: "تبديل اللوحة الجانبية",
|
||||||
toggleBothSidebars: "تبديل كلا الشريطين الجانبيين",
|
toggleBothSidebars: "تبديل كلا الشريطين الجانبيين",
|
||||||
toggleSettings: "تبديل الإعدادات",
|
toggleSettings: "تبديل الإعدادات",
|
||||||
toggleFocusMode: "تبديل وضع التركيز",
|
toggleFocusMode: "تبديل وضع التركيز",
|
||||||
toggleExplorerPaneMaximization: "تبديل تكبير جزء المستكشف",
|
toggleExplorerPaneMaximization: "تبديل تكبير اللوحة الجانبية",
|
||||||
cycleTheme: "موضوع الدورة",
|
cycleTheme: "موضوع الدورة",
|
||||||
focusMessageInput: "التركيز على إدخال الرسالة",
|
focusMessageInput: "التركيز على إدخال الرسالة",
|
||||||
cycleAgentMode: "تبديل وضع الوكيل",
|
cycleAgentMode: "تبديل وضع الوكيل",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@ export const en = {
|
|||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
modified: "Unsaved changes",
|
modified: "Unsaved changes",
|
||||||
loadingAgentTitle: "Loading agent title",
|
loadingAgentTitle: "Loading agent title",
|
||||||
emptyPane: "No tabs in this pane.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "New agent",
|
newAgent: "New agent",
|
||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
@@ -593,6 +592,7 @@ export const en = {
|
|||||||
newBrowser: "New browser",
|
newBrowser: "New browser",
|
||||||
maximizePane: "Maximize pane",
|
maximizePane: "Maximize pane",
|
||||||
restorePane: "Restore pane",
|
restorePane: "Restore pane",
|
||||||
|
closePane: "Close pane",
|
||||||
exitFocusMode: "Exit focus mode",
|
exitFocusMode: "Exit focus mode",
|
||||||
splitRight: "Split pane right",
|
splitRight: "Split pane right",
|
||||||
splitDown: "Split pane down",
|
splitDown: "Split pane down",
|
||||||
@@ -602,10 +602,10 @@ export const en = {
|
|||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "Open explorer",
|
open: "Open side panel",
|
||||||
close: "Close explorer",
|
close: "Close side panel",
|
||||||
toggle: "Toggle explorer",
|
toggle: "Toggle side panel",
|
||||||
changes: "Changes",
|
changes: "Changes",
|
||||||
files: "Files",
|
files: "Files",
|
||||||
},
|
},
|
||||||
@@ -706,6 +706,10 @@ export const en = {
|
|||||||
stopFailed: "Failed to stop {{scriptName}}",
|
stopFailed: "Failed to stop {{scriptName}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "Show folder tree",
|
||||||
|
hideFolderTree: "Hide folder tree",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "More options",
|
moreOptions: "More options",
|
||||||
@@ -864,8 +868,6 @@ export const en = {
|
|||||||
split: "Side-by-side diff",
|
split: "Side-by-side diff",
|
||||||
switchToUnified: "Switch to unified diff",
|
switchToUnified: "Switch to unified diff",
|
||||||
switchToSplit: "Switch to side-by-side diff",
|
switchToSplit: "Switch to side-by-side diff",
|
||||||
showTreeView: "Show folder tree",
|
|
||||||
hideTreeView: "Hide folder tree",
|
|
||||||
options: "Diff options",
|
options: "Diff options",
|
||||||
hideWhitespace: "Hide whitespace",
|
hideWhitespace: "Hide whitespace",
|
||||||
showWhitespace: "Show whitespace",
|
showWhitespace: "Show whitespace",
|
||||||
@@ -1981,6 +1983,11 @@ export const en = {
|
|||||||
description: "Lines kept in the built-in terminal buffer",
|
description: "Lines kept in the built-in terminal buffer",
|
||||||
accessibilityLabel: "Terminal scrollback lines",
|
accessibilityLabel: "Terminal scrollback lines",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "Open supporting tabs in the Side panel",
|
||||||
|
description:
|
||||||
|
"File links, pull requests, and setup progress open beside your work instead of in the focused pane",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "Always expand reasoning",
|
label: "Always expand reasoning",
|
||||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||||
@@ -2164,11 +2171,11 @@ export const en = {
|
|||||||
toggleCommandCenter: "Toggle command center",
|
toggleCommandCenter: "Toggle command center",
|
||||||
showKeyboardShortcuts: "Show keyboard shortcuts",
|
showKeyboardShortcuts: "Show keyboard shortcuts",
|
||||||
toggleLeftSidebar: "Toggle left sidebar",
|
toggleLeftSidebar: "Toggle left sidebar",
|
||||||
toggleRightSidebar: "Toggle right sidebar",
|
toggleRightSidebar: "Toggle side panel",
|
||||||
toggleBothSidebars: "Toggle both sidebars",
|
toggleBothSidebars: "Toggle both sidebars",
|
||||||
toggleSettings: "Toggle settings",
|
toggleSettings: "Toggle settings",
|
||||||
toggleFocusMode: "Toggle focus mode",
|
toggleFocusMode: "Toggle focus mode",
|
||||||
toggleExplorerPaneMaximization: "Toggle Explorer pane maximization",
|
toggleExplorerPaneMaximization: "Toggle Side panel maximization",
|
||||||
cycleTheme: "Cycle theme",
|
cycleTheme: "Cycle theme",
|
||||||
focusMessageInput: "Focus message input",
|
focusMessageInput: "Focus message input",
|
||||||
cycleAgentMode: "Cycle agent mode",
|
cycleAgentMode: "Cycle agent mode",
|
||||||
|
|||||||
@@ -557,7 +557,6 @@ export const es: TranslationResources = {
|
|||||||
loading: "Cargando...",
|
loading: "Cargando...",
|
||||||
modified: "Cambios sin guardar",
|
modified: "Cambios sin guardar",
|
||||||
loadingAgentTitle: "Título del agente de carga",
|
loadingAgentTitle: "Título del agente de carga",
|
||||||
emptyPane: "No hay pestañas en este panel.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "Nuevo agente",
|
newAgent: "Nuevo agente",
|
||||||
setup: "Configuración",
|
setup: "Configuración",
|
||||||
@@ -600,6 +599,7 @@ export const es: TranslationResources = {
|
|||||||
newBrowser: "Nuevo navegador",
|
newBrowser: "Nuevo navegador",
|
||||||
maximizePane: "Maximizar panel",
|
maximizePane: "Maximizar panel",
|
||||||
restorePane: "Restaurar panel",
|
restorePane: "Restaurar panel",
|
||||||
|
closePane: "Cerrar panel",
|
||||||
exitFocusMode: "Salir del modo de concentración",
|
exitFocusMode: "Salir del modo de concentración",
|
||||||
splitRight: "Panel dividido a la derecha",
|
splitRight: "Panel dividido a la derecha",
|
||||||
splitDown: "Dividir panel hacia abajo",
|
splitDown: "Dividir panel hacia abajo",
|
||||||
@@ -609,10 +609,10 @@ export const es: TranslationResources = {
|
|||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "Explorador abierto",
|
open: "Abrir panel lateral",
|
||||||
close: "Cerrar explorador",
|
close: "Cerrar panel lateral",
|
||||||
toggle: "Alternar explorador",
|
toggle: "Alternar panel lateral",
|
||||||
changes: "Cambios",
|
changes: "Cambios",
|
||||||
files: "Archivos",
|
files: "Archivos",
|
||||||
},
|
},
|
||||||
@@ -716,6 +716,10 @@ export const es: TranslationResources = {
|
|||||||
stopFailed: "No se pudo detener{{scriptName}}",
|
stopFailed: "No se pudo detener{{scriptName}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "Mostrar árbol de carpetas",
|
||||||
|
hideFolderTree: "Ocultar árbol de carpetas",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "Más opciones",
|
moreOptions: "Más opciones",
|
||||||
@@ -886,8 +890,6 @@ export const es: TranslationResources = {
|
|||||||
split: "Diferencia de lado a lado",
|
split: "Diferencia de lado a lado",
|
||||||
switchToUnified: "Cambiar a diferencia unificada",
|
switchToUnified: "Cambiar a diferencia unificada",
|
||||||
switchToSplit: "Cambiar a diferencia lado a lado",
|
switchToSplit: "Cambiar a diferencia lado a lado",
|
||||||
showTreeView: "Mostrar árbol de carpetas",
|
|
||||||
hideTreeView: "Ocultar árbol de carpetas",
|
|
||||||
options: "Opciones de diferencia",
|
options: "Opciones de diferencia",
|
||||||
hideWhitespace: "Ocultar espacios en blanco",
|
hideWhitespace: "Ocultar espacios en blanco",
|
||||||
showWhitespace: "Mostrar espacios en blanco",
|
showWhitespace: "Mostrar espacios en blanco",
|
||||||
@@ -1966,6 +1968,11 @@ export const es: TranslationResources = {
|
|||||||
description: "Líneas mantenidas en el búfer de terminal incorporado",
|
description: "Líneas mantenidas en el búfer de terminal incorporado",
|
||||||
accessibilityLabel: "Líneas del historial de terminal",
|
accessibilityLabel: "Líneas del historial de terminal",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "Abrir pestañas de apoyo en el panel lateral",
|
||||||
|
description:
|
||||||
|
"Los enlaces a archivos, las solicitudes de incorporación y el progreso de configuración se abren junto a tu trabajo en lugar del panel enfocado",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "Siempre expandir razonamiento",
|
label: "Siempre expandir razonamiento",
|
||||||
description:
|
description:
|
||||||
@@ -2151,11 +2158,11 @@ export const es: TranslationResources = {
|
|||||||
toggleCommandCenter: "Alternar centro de comando",
|
toggleCommandCenter: "Alternar centro de comando",
|
||||||
showKeyboardShortcuts: "Mostrar atajos de teclado",
|
showKeyboardShortcuts: "Mostrar atajos de teclado",
|
||||||
toggleLeftSidebar: "Alternar barra lateral izquierda",
|
toggleLeftSidebar: "Alternar barra lateral izquierda",
|
||||||
toggleRightSidebar: "Alternar barra lateral derecha",
|
toggleRightSidebar: "Alternar panel lateral",
|
||||||
toggleBothSidebars: "Alternar ambas barras laterales",
|
toggleBothSidebars: "Alternar ambas barras laterales",
|
||||||
toggleSettings: "Alternar configuración",
|
toggleSettings: "Alternar configuración",
|
||||||
toggleFocusMode: "Alternar modo de enfoque",
|
toggleFocusMode: "Alternar modo de enfoque",
|
||||||
toggleExplorerPaneMaximization: "Alternar maximización del panel Explorador",
|
toggleExplorerPaneMaximization: "Alternar maximización del panel lateral",
|
||||||
cycleTheme: "Tema del ciclo",
|
cycleTheme: "Tema del ciclo",
|
||||||
focusMessageInput: "Entrada de mensaje de enfoque",
|
focusMessageInput: "Entrada de mensaje de enfoque",
|
||||||
cycleAgentMode: "Alternar modo del agente",
|
cycleAgentMode: "Alternar modo del agente",
|
||||||
|
|||||||
@@ -557,7 +557,6 @@ export const fr: TranslationResources = {
|
|||||||
loading: "Chargement...",
|
loading: "Chargement...",
|
||||||
modified: "Modifications non enregistrées",
|
modified: "Modifications non enregistrées",
|
||||||
loadingAgentTitle: "Titre d'agent de chargement",
|
loadingAgentTitle: "Titre d'agent de chargement",
|
||||||
emptyPane: "Aucun onglet dans ce volet.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "Nouvel agent",
|
newAgent: "Nouvel agent",
|
||||||
setup: "Installation",
|
setup: "Installation",
|
||||||
@@ -600,6 +599,7 @@ export const fr: TranslationResources = {
|
|||||||
newBrowser: "Nouveau navigateur",
|
newBrowser: "Nouveau navigateur",
|
||||||
maximizePane: "Agrandir le volet",
|
maximizePane: "Agrandir le volet",
|
||||||
restorePane: "Restaurer le volet",
|
restorePane: "Restaurer le volet",
|
||||||
|
closePane: "Fermer le volet",
|
||||||
exitFocusMode: "Quitter le mode concentration",
|
exitFocusMode: "Quitter le mode concentration",
|
||||||
splitRight: "Volet divisé à droite",
|
splitRight: "Volet divisé à droite",
|
||||||
splitDown: "Diviser le volet vers le bas",
|
splitDown: "Diviser le volet vers le bas",
|
||||||
@@ -609,10 +609,10 @@ export const fr: TranslationResources = {
|
|||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "Ouvrir l'explorateur",
|
open: "Ouvrir le panneau latéral",
|
||||||
close: "Fermer l'explorateur",
|
close: "Fermer le panneau latéral",
|
||||||
toggle: "Basculer l'explorateur",
|
toggle: "Basculer le panneau latéral",
|
||||||
changes: "Changements",
|
changes: "Changements",
|
||||||
files: "Fichiers",
|
files: "Fichiers",
|
||||||
},
|
},
|
||||||
@@ -716,6 +716,10 @@ export const fr: TranslationResources = {
|
|||||||
stopFailed: "Échec de l'arrêt de{{scriptName}}",
|
stopFailed: "Échec de l'arrêt de{{scriptName}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "Afficher l'arborescence des dossiers",
|
||||||
|
hideFolderTree: "Masquer l'arborescence des dossiers",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "Plus d'options",
|
moreOptions: "Plus d'options",
|
||||||
@@ -885,8 +889,6 @@ export const fr: TranslationResources = {
|
|||||||
split: "Différent côte à côte",
|
split: "Différent côte à côte",
|
||||||
switchToUnified: "Passer au diff unifié",
|
switchToUnified: "Passer au diff unifié",
|
||||||
switchToSplit: "Passer au diff côte à côte",
|
switchToSplit: "Passer au diff côte à côte",
|
||||||
showTreeView: "Afficher l'arborescence des dossiers",
|
|
||||||
hideTreeView: "Masquer l'arborescence des dossiers",
|
|
||||||
options: "Options du diff",
|
options: "Options du diff",
|
||||||
hideWhitespace: "Masquer les espaces",
|
hideWhitespace: "Masquer les espaces",
|
||||||
showWhitespace: "Afficher les espaces",
|
showWhitespace: "Afficher les espaces",
|
||||||
@@ -1970,6 +1972,11 @@ export const fr: TranslationResources = {
|
|||||||
description: "Lignes conservées dans le tampon du terminal intégré",
|
description: "Lignes conservées dans le tampon du terminal intégré",
|
||||||
accessibilityLabel: "Lignes de défilementTerminal",
|
accessibilityLabel: "Lignes de défilementTerminal",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "Ouvrir les onglets d'appoint dans le panneau latéral",
|
||||||
|
description:
|
||||||
|
"Les liens de fichiers, les pull requests et la progression de la configuration s'ouvrent à côté de votre travail plutôt que dans le volet actif",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "Toujours afficher le raisonnement",
|
label: "Toujours afficher le raisonnement",
|
||||||
description: "Afficher le raisonnement de l'agent entièrement développé par défaut",
|
description: "Afficher le raisonnement de l'agent entièrement développé par défaut",
|
||||||
@@ -2155,11 +2162,11 @@ export const fr: TranslationResources = {
|
|||||||
toggleCommandCenter: "Basculer le centre de commande",
|
toggleCommandCenter: "Basculer le centre de commande",
|
||||||
showKeyboardShortcuts: "Afficher les raccourcis clavier",
|
showKeyboardShortcuts: "Afficher les raccourcis clavier",
|
||||||
toggleLeftSidebar: "Basculer la barre latérale gauche",
|
toggleLeftSidebar: "Basculer la barre latérale gauche",
|
||||||
toggleRightSidebar: "Basculer la barre latérale droite",
|
toggleRightSidebar: "Basculer le panneau latéral",
|
||||||
toggleBothSidebars: "Basculer les deux barres latérales",
|
toggleBothSidebars: "Basculer les deux barres latérales",
|
||||||
toggleSettings: "Basculer les paramètres",
|
toggleSettings: "Basculer les paramètres",
|
||||||
toggleFocusMode: "Basculer le mode de mise au point",
|
toggleFocusMode: "Basculer le mode de mise au point",
|
||||||
toggleExplorerPaneMaximization: "Basculer l’agrandissement du volet Explorateur",
|
toggleExplorerPaneMaximization: "Basculer l’agrandissement du panneau latéral",
|
||||||
cycleTheme: "Thème du cycle",
|
cycleTheme: "Thème du cycle",
|
||||||
focusMessageInput: "Saisie du message de focus",
|
focusMessageInput: "Saisie du message de focus",
|
||||||
cycleAgentMode: "Parcourir les modes de l'agent",
|
cycleAgentMode: "Parcourir les modes de l'agent",
|
||||||
|
|||||||
@@ -557,7 +557,6 @@ export const ja: TranslationResources = {
|
|||||||
loading: "読み込み中...",
|
loading: "読み込み中...",
|
||||||
modified: "未保存の変更",
|
modified: "未保存の変更",
|
||||||
loadingAgentTitle: "エージェントタイトルを読み込み中",
|
loadingAgentTitle: "エージェントタイトルを読み込み中",
|
||||||
emptyPane: "このペインにタブがありません。",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "新しいエージェント",
|
newAgent: "新しいエージェント",
|
||||||
setup: "セットアップ",
|
setup: "セットアップ",
|
||||||
@@ -600,6 +599,7 @@ export const ja: TranslationResources = {
|
|||||||
newBrowser: "新しいブラウザ",
|
newBrowser: "新しいブラウザ",
|
||||||
maximizePane: "ペインを最大化",
|
maximizePane: "ペインを最大化",
|
||||||
restorePane: "ペインを元に戻す",
|
restorePane: "ペインを元に戻す",
|
||||||
|
closePane: "ペインを閉じる",
|
||||||
exitFocusMode: "フォーカスモードを終了",
|
exitFocusMode: "フォーカスモードを終了",
|
||||||
splitRight: "右にペインを分割",
|
splitRight: "右にペインを分割",
|
||||||
splitDown: "下にペインを分割",
|
splitDown: "下にペインを分割",
|
||||||
@@ -609,10 +609,10 @@ export const ja: TranslationResources = {
|
|||||||
terminalProfilesMenu: "ターミナルプロファイル",
|
terminalProfilesMenu: "ターミナルプロファイル",
|
||||||
editTerminalProfiles: "プロファイルを編集…",
|
editTerminalProfiles: "プロファイルを編集…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "エクスプローラーを開く",
|
open: "サイドパネルを開く",
|
||||||
close: "エクスプローラーを閉じる",
|
close: "サイドパネルを閉じる",
|
||||||
toggle: "エクスプローラーを切り替え",
|
toggle: "サイドパネルを切り替え",
|
||||||
changes: "変更",
|
changes: "変更",
|
||||||
files: "ファイル",
|
files: "ファイル",
|
||||||
},
|
},
|
||||||
@@ -712,6 +712,10 @@ export const ja: TranslationResources = {
|
|||||||
stopFailed: "{{scriptName}}の停止に失敗しました",
|
stopFailed: "{{scriptName}}の停止に失敗しました",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "フォルダツリーを表示",
|
||||||
|
hideFolderTree: "フォルダツリーを非表示",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "その他のオプション",
|
moreOptions: "その他のオプション",
|
||||||
@@ -866,8 +870,6 @@ export const ja: TranslationResources = {
|
|||||||
split: "左右比較",
|
split: "左右比較",
|
||||||
switchToUnified: "ユニファイド差分に切り替え",
|
switchToUnified: "ユニファイド差分に切り替え",
|
||||||
switchToSplit: "左右比較差分に切り替え",
|
switchToSplit: "左右比較差分に切り替え",
|
||||||
showTreeView: "フォルダツリーを表示",
|
|
||||||
hideTreeView: "フォルダツリーを非表示",
|
|
||||||
options: "差分オプション",
|
options: "差分オプション",
|
||||||
hideWhitespace: "空白を非表示",
|
hideWhitespace: "空白を非表示",
|
||||||
showWhitespace: "空白を表示",
|
showWhitespace: "空白を表示",
|
||||||
@@ -1934,6 +1936,11 @@ export const ja: TranslationResources = {
|
|||||||
description: "組み込みターミナルバッファに保持する行数",
|
description: "組み込みターミナルバッファに保持する行数",
|
||||||
accessibilityLabel: "ターミナルスクロールバック行数",
|
accessibilityLabel: "ターミナルスクロールバック行数",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "補助タブをサイドパネルで開く",
|
||||||
|
description:
|
||||||
|
"ファイルリンク、プルリクエスト、セットアップの進行状況が、フォーカス中のペインではなく作業の横に開きます",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "常に思考プロセスを展開",
|
label: "常に思考プロセスを展開",
|
||||||
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
||||||
@@ -2118,11 +2125,11 @@ export const ja: TranslationResources = {
|
|||||||
toggleCommandCenter: "コマンドセンターを切り替え",
|
toggleCommandCenter: "コマンドセンターを切り替え",
|
||||||
showKeyboardShortcuts: "キーボードショートカットを表示",
|
showKeyboardShortcuts: "キーボードショートカットを表示",
|
||||||
toggleLeftSidebar: "左サイドバーを切り替え",
|
toggleLeftSidebar: "左サイドバーを切り替え",
|
||||||
toggleRightSidebar: "右サイドバーを切り替え",
|
toggleRightSidebar: "サイドパネルを切り替え",
|
||||||
toggleBothSidebars: "両方のサイドバーを切り替え",
|
toggleBothSidebars: "両方のサイドバーを切り替え",
|
||||||
toggleSettings: "設定を切り替え",
|
toggleSettings: "設定を切り替え",
|
||||||
toggleFocusMode: "フォーカスモードを切り替え",
|
toggleFocusMode: "フォーカスモードを切り替え",
|
||||||
toggleExplorerPaneMaximization: "エクスプローラーペインの最大化を切り替え",
|
toggleExplorerPaneMaximization: "サイドパネルの最大化を切り替え",
|
||||||
cycleTheme: "テーマを順に切り替え",
|
cycleTheme: "テーマを順に切り替え",
|
||||||
focusMessageInput: "メッセージ入力にフォーカス",
|
focusMessageInput: "メッセージ入力にフォーカス",
|
||||||
cycleAgentMode: "エージェントモードを順に切り替え",
|
cycleAgentMode: "エージェントモードを順に切り替え",
|
||||||
|
|||||||
@@ -554,7 +554,6 @@ export const ko: TranslationResources = {
|
|||||||
loading: "불러오는 중...",
|
loading: "불러오는 중...",
|
||||||
modified: "저장되지 않은 변경사항",
|
modified: "저장되지 않은 변경사항",
|
||||||
loadingAgentTitle: "에이전트 제목 불러오는 중",
|
loadingAgentTitle: "에이전트 제목 불러오는 중",
|
||||||
emptyPane: "이 창에 탭이 없습니다.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "새 에이전트",
|
newAgent: "새 에이전트",
|
||||||
setup: "설정",
|
setup: "설정",
|
||||||
@@ -597,6 +596,7 @@ export const ko: TranslationResources = {
|
|||||||
newBrowser: "새 브라우저",
|
newBrowser: "새 브라우저",
|
||||||
maximizePane: "창 최대화",
|
maximizePane: "창 최대화",
|
||||||
restorePane: "창 복원",
|
restorePane: "창 복원",
|
||||||
|
closePane: "창 닫기",
|
||||||
exitFocusMode: "집중 모드 종료",
|
exitFocusMode: "집중 모드 종료",
|
||||||
splitRight: "창을 오른쪽으로 분할",
|
splitRight: "창을 오른쪽으로 분할",
|
||||||
splitDown: "창을 아래로 분할",
|
splitDown: "창을 아래로 분할",
|
||||||
@@ -606,10 +606,10 @@ export const ko: TranslationResources = {
|
|||||||
terminalProfilesMenu: "터미널 프로필",
|
terminalProfilesMenu: "터미널 프로필",
|
||||||
editTerminalProfiles: "프로필 편집…",
|
editTerminalProfiles: "프로필 편집…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "탐색기 열기",
|
open: "사이드 패널 열기",
|
||||||
close: "탐색기 닫기",
|
close: "사이드 패널 닫기",
|
||||||
toggle: "탐색기 토글",
|
toggle: "사이드 패널 토글",
|
||||||
changes: "변경 사항",
|
changes: "변경 사항",
|
||||||
files: "파일",
|
files: "파일",
|
||||||
},
|
},
|
||||||
@@ -710,6 +710,10 @@ export const ko: TranslationResources = {
|
|||||||
stopFailed: "{{scriptName}}를 중지하지 못했습니다.",
|
stopFailed: "{{scriptName}}를 중지하지 못했습니다.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "폴더 트리 표시",
|
||||||
|
hideFolderTree: "폴더 트리 숨기기",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "옵션 더 보기",
|
moreOptions: "옵션 더 보기",
|
||||||
@@ -862,8 +866,6 @@ export const ko: TranslationResources = {
|
|||||||
split: "나란히 보기 diff",
|
split: "나란히 보기 diff",
|
||||||
switchToUnified: "통합 diff로 전환",
|
switchToUnified: "통합 diff로 전환",
|
||||||
switchToSplit: "나란히 보기 diff로 전환",
|
switchToSplit: "나란히 보기 diff로 전환",
|
||||||
showTreeView: "폴더 트리 표시",
|
|
||||||
hideTreeView: "폴더 트리 숨기기",
|
|
||||||
options: "Diff 옵션",
|
options: "Diff 옵션",
|
||||||
hideWhitespace: "공백 숨기기",
|
hideWhitespace: "공백 숨기기",
|
||||||
showWhitespace: "공백 표시",
|
showWhitespace: "공백 표시",
|
||||||
@@ -1928,6 +1930,11 @@ export const ko: TranslationResources = {
|
|||||||
description: "내장 터미널 버퍼에 보관되는 줄 수",
|
description: "내장 터미널 버퍼에 보관되는 줄 수",
|
||||||
accessibilityLabel: "터미널 스크롤백 줄 수",
|
accessibilityLabel: "터미널 스크롤백 줄 수",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "보조 탭을 사이드 패널에서 열기",
|
||||||
|
description:
|
||||||
|
"파일 링크, 풀 리퀘스트, 설정 진행 상황이 포커스된 창이 아니라 작업 옆에서 열립니다",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "추론 항상 펼치기",
|
label: "추론 항상 펼치기",
|
||||||
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
|
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
|
||||||
@@ -2111,11 +2118,11 @@ export const ko: TranslationResources = {
|
|||||||
toggleCommandCenter: "명령 센터 토글",
|
toggleCommandCenter: "명령 센터 토글",
|
||||||
showKeyboardShortcuts: "키보드 단축키 표시",
|
showKeyboardShortcuts: "키보드 단축키 표시",
|
||||||
toggleLeftSidebar: "왼쪽 사이드바 토글",
|
toggleLeftSidebar: "왼쪽 사이드바 토글",
|
||||||
toggleRightSidebar: "오른쪽 사이드바 토글",
|
toggleRightSidebar: "사이드 패널 토글",
|
||||||
toggleBothSidebars: "양쪽 사이드바 토글",
|
toggleBothSidebars: "양쪽 사이드바 토글",
|
||||||
toggleSettings: "설정 토글",
|
toggleSettings: "설정 토글",
|
||||||
toggleFocusMode: "집중 모드 토글",
|
toggleFocusMode: "집중 모드 토글",
|
||||||
toggleExplorerPaneMaximization: "탐색기 창 최대화 전환",
|
toggleExplorerPaneMaximization: "사이드 패널 최대화 전환",
|
||||||
cycleTheme: "테마 순환",
|
cycleTheme: "테마 순환",
|
||||||
focusMessageInput: "메시지 입력란에 포커스",
|
focusMessageInput: "메시지 입력란에 포커스",
|
||||||
cycleAgentMode: "에이전트 모드 전환",
|
cycleAgentMode: "에이전트 모드 전환",
|
||||||
|
|||||||
@@ -557,7 +557,6 @@ export const ptBR: TranslationResources = {
|
|||||||
loading: "Carregando...",
|
loading: "Carregando...",
|
||||||
modified: "Alterações não salvas",
|
modified: "Alterações não salvas",
|
||||||
loadingAgentTitle: "Carregando título do agente",
|
loadingAgentTitle: "Carregando título do agente",
|
||||||
emptyPane: "Nenhuma aba neste painel.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "Novo agente",
|
newAgent: "Novo agente",
|
||||||
setup: "Configuração",
|
setup: "Configuração",
|
||||||
@@ -599,6 +598,7 @@ export const ptBR: TranslationResources = {
|
|||||||
newBrowser: "Novo navegador",
|
newBrowser: "Novo navegador",
|
||||||
maximizePane: "Maximizar painel",
|
maximizePane: "Maximizar painel",
|
||||||
restorePane: "Restaurar painel",
|
restorePane: "Restaurar painel",
|
||||||
|
closePane: "Fechar painel",
|
||||||
exitFocusMode: "Sair do modo de foco",
|
exitFocusMode: "Sair do modo de foco",
|
||||||
splitRight: "Dividir painel à direita",
|
splitRight: "Dividir painel à direita",
|
||||||
splitDown: "Dividir painel abaixo",
|
splitDown: "Dividir painel abaixo",
|
||||||
@@ -608,10 +608,10 @@ export const ptBR: TranslationResources = {
|
|||||||
terminalProfilesMenu: "Perfis de terminal",
|
terminalProfilesMenu: "Perfis de terminal",
|
||||||
editTerminalProfiles: "Editar perfis…",
|
editTerminalProfiles: "Editar perfis…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "Abrir explorador",
|
open: "Abrir painel lateral",
|
||||||
close: "Fechar explorador",
|
close: "Fechar painel lateral",
|
||||||
toggle: "Alternar explorador",
|
toggle: "Alternar painel lateral",
|
||||||
changes: "Alterações",
|
changes: "Alterações",
|
||||||
files: "Arquivos",
|
files: "Arquivos",
|
||||||
},
|
},
|
||||||
@@ -713,6 +713,10 @@ export const ptBR: TranslationResources = {
|
|||||||
stopFailed: "Falha ao parar {{scriptName}}",
|
stopFailed: "Falha ao parar {{scriptName}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "Mostrar árvore de pastas",
|
||||||
|
hideFolderTree: "Ocultar árvore de pastas",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "Mais opções",
|
moreOptions: "Mais opções",
|
||||||
@@ -877,8 +881,6 @@ export const ptBR: TranslationResources = {
|
|||||||
split: "Diff lado a lado",
|
split: "Diff lado a lado",
|
||||||
switchToUnified: "Alternar para diff unificado",
|
switchToUnified: "Alternar para diff unificado",
|
||||||
switchToSplit: "Alternar para diff lado a lado",
|
switchToSplit: "Alternar para diff lado a lado",
|
||||||
showTreeView: "Mostrar árvore de pastas",
|
|
||||||
hideTreeView: "Ocultar árvore de pastas",
|
|
||||||
options: "Opções do diff",
|
options: "Opções do diff",
|
||||||
hideWhitespace: "Ocultar espaços em branco",
|
hideWhitespace: "Ocultar espaços em branco",
|
||||||
showWhitespace: "Mostrar espaços em branco",
|
showWhitespace: "Mostrar espaços em branco",
|
||||||
@@ -1950,6 +1952,11 @@ export const ptBR: TranslationResources = {
|
|||||||
description: "Linhas mantidas no buffer do terminal integrado",
|
description: "Linhas mantidas no buffer do terminal integrado",
|
||||||
accessibilityLabel: "Linhas do scrollback do terminal",
|
accessibilityLabel: "Linhas do scrollback do terminal",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "Abrir abas de apoio no painel lateral",
|
||||||
|
description:
|
||||||
|
"Links de arquivo, pull requests e progresso da configuração abrem ao lado do seu trabalho em vez do painel em foco",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "Sempre expandir raciocínio",
|
label: "Sempre expandir raciocínio",
|
||||||
description:
|
description:
|
||||||
@@ -2134,11 +2141,11 @@ export const ptBR: TranslationResources = {
|
|||||||
toggleCommandCenter: "Alternar central de comandos",
|
toggleCommandCenter: "Alternar central de comandos",
|
||||||
showKeyboardShortcuts: "Mostrar atalhos de teclado",
|
showKeyboardShortcuts: "Mostrar atalhos de teclado",
|
||||||
toggleLeftSidebar: "Alternar barra lateral esquerda",
|
toggleLeftSidebar: "Alternar barra lateral esquerda",
|
||||||
toggleRightSidebar: "Alternar barra lateral direita",
|
toggleRightSidebar: "Alternar painel lateral",
|
||||||
toggleBothSidebars: "Alternar ambas as barras laterais",
|
toggleBothSidebars: "Alternar ambas as barras laterais",
|
||||||
toggleSettings: "Alternar Configurações",
|
toggleSettings: "Alternar Configurações",
|
||||||
toggleFocusMode: "Alternar modo de foco",
|
toggleFocusMode: "Alternar modo de foco",
|
||||||
toggleExplorerPaneMaximization: "Alternar maximização do painel Explorador",
|
toggleExplorerPaneMaximization: "Alternar maximização do painel lateral",
|
||||||
cycleTheme: "Alternar tema",
|
cycleTheme: "Alternar tema",
|
||||||
focusMessageInput: "Focar entrada de mensagem",
|
focusMessageInput: "Focar entrada de mensagem",
|
||||||
cycleAgentMode: "Alternar modo do agente",
|
cycleAgentMode: "Alternar modo do agente",
|
||||||
|
|||||||
@@ -557,7 +557,6 @@ export const ru: TranslationResources = {
|
|||||||
loading: "Загрузка...",
|
loading: "Загрузка...",
|
||||||
modified: "Несохранённые изменения",
|
modified: "Несохранённые изменения",
|
||||||
loadingAgentTitle: "Название агента загрузки",
|
loadingAgentTitle: "Название агента загрузки",
|
||||||
emptyPane: "На этой панели нет вкладок.",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "Новый агент",
|
newAgent: "Новый агент",
|
||||||
setup: "Настраивать",
|
setup: "Настраивать",
|
||||||
@@ -599,6 +598,7 @@ export const ru: TranslationResources = {
|
|||||||
newBrowser: "Новый браузер",
|
newBrowser: "Новый браузер",
|
||||||
maximizePane: "Развернуть панель",
|
maximizePane: "Развернуть панель",
|
||||||
restorePane: "Восстановить панель",
|
restorePane: "Восстановить панель",
|
||||||
|
closePane: "Закрыть панель",
|
||||||
exitFocusMode: "Выйти из режима фокусировки",
|
exitFocusMode: "Выйти из режима фокусировки",
|
||||||
splitRight: "Разделить панель справа",
|
splitRight: "Разделить панель справа",
|
||||||
splitDown: "Разделить панель вниз",
|
splitDown: "Разделить панель вниз",
|
||||||
@@ -608,10 +608,10 @@ export const ru: TranslationResources = {
|
|||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "Открыть проводник",
|
open: "Открыть боковую панель",
|
||||||
close: "Закрыть проводник",
|
close: "Закрыть боковую панель",
|
||||||
toggle: "Переключить проводник",
|
toggle: "Переключить боковую панель",
|
||||||
changes: "Изменения",
|
changes: "Изменения",
|
||||||
files: "Файлы",
|
files: "Файлы",
|
||||||
},
|
},
|
||||||
@@ -715,6 +715,10 @@ export const ru: TranslationResources = {
|
|||||||
stopFailed: "Не удалось остановить{{scriptName}}",
|
stopFailed: "Не удалось остановить{{scriptName}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "Показать дерево папок",
|
||||||
|
hideFolderTree: "Скрыть дерево папок",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "Больше возможностей",
|
moreOptions: "Больше возможностей",
|
||||||
@@ -877,8 +881,6 @@ export const ru: TranslationResources = {
|
|||||||
split: "Параллельная разница",
|
split: "Параллельная разница",
|
||||||
switchToUnified: "Переключить на объединенный diff",
|
switchToUnified: "Переключить на объединенный diff",
|
||||||
switchToSplit: "Переключить на diff рядом",
|
switchToSplit: "Переключить на diff рядом",
|
||||||
showTreeView: "Показать дерево папок",
|
|
||||||
hideTreeView: "Скрыть дерево папок",
|
|
||||||
options: "Параметры diff",
|
options: "Параметры diff",
|
||||||
hideWhitespace: "Скрыть пробелы",
|
hideWhitespace: "Скрыть пробелы",
|
||||||
showWhitespace: "Показать пробелы",
|
showWhitespace: "Показать пробелы",
|
||||||
@@ -1953,6 +1955,11 @@ export const ru: TranslationResources = {
|
|||||||
description: "Строки, хранящиеся во встроенном буфере терминала.",
|
description: "Строки, хранящиеся во встроенном буфере терминала.",
|
||||||
accessibilityLabel: "Линии прокрутки Terminal",
|
accessibilityLabel: "Линии прокрутки Terminal",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "Открывать вспомогательные вкладки в боковой панели",
|
||||||
|
description:
|
||||||
|
"Ссылки на файлы, пул-реквесты и ход настройки открываются рядом с вашей работой, а не в активной панели",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "Всегда разворачивать размышления",
|
label: "Всегда разворачивать размышления",
|
||||||
description:
|
description:
|
||||||
@@ -2140,11 +2147,11 @@ export const ru: TranslationResources = {
|
|||||||
toggleCommandCenter: "Переключить командный центр",
|
toggleCommandCenter: "Переключить командный центр",
|
||||||
showKeyboardShortcuts: "Показать сочетания клавиш",
|
showKeyboardShortcuts: "Показать сочетания клавиш",
|
||||||
toggleLeftSidebar: "Переключить левую боковую панель",
|
toggleLeftSidebar: "Переключить левую боковую панель",
|
||||||
toggleRightSidebar: "Переключить правую боковую панель",
|
toggleRightSidebar: "Переключить боковую панель",
|
||||||
toggleBothSidebars: "Переключить обе боковые панели",
|
toggleBothSidebars: "Переключить обе боковые панели",
|
||||||
toggleSettings: "Переключить настройки",
|
toggleSettings: "Переключить настройки",
|
||||||
toggleFocusMode: "Переключить режим фокусировки",
|
toggleFocusMode: "Переключить режим фокусировки",
|
||||||
toggleExplorerPaneMaximization: "Переключить разворачивание панели Проводника",
|
toggleExplorerPaneMaximization: "Переключить разворачивание боковой панели",
|
||||||
cycleTheme: "Циклическая тема",
|
cycleTheme: "Циклическая тема",
|
||||||
focusMessageInput: "Фокус ввода сообщения",
|
focusMessageInput: "Фокус ввода сообщения",
|
||||||
cycleAgentMode: "Переключить режим агента",
|
cycleAgentMode: "Переключить режим агента",
|
||||||
|
|||||||
@@ -553,7 +553,6 @@ export const zhCN: TranslationResources = {
|
|||||||
loading: "正在加载...",
|
loading: "正在加载...",
|
||||||
modified: "未保存的更改",
|
modified: "未保存的更改",
|
||||||
loadingAgentTitle: "正在加载 Agent 标题",
|
loadingAgentTitle: "正在加载 Agent 标题",
|
||||||
emptyPane: "此窗格中没有标签。",
|
|
||||||
fallback: {
|
fallback: {
|
||||||
newAgent: "新建 Agent",
|
newAgent: "新建 Agent",
|
||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
@@ -595,6 +594,7 @@ export const zhCN: TranslationResources = {
|
|||||||
newBrowser: "新建浏览器",
|
newBrowser: "新建浏览器",
|
||||||
maximizePane: "最大化窗格",
|
maximizePane: "最大化窗格",
|
||||||
restorePane: "还原窗格",
|
restorePane: "还原窗格",
|
||||||
|
closePane: "关闭窗格",
|
||||||
exitFocusMode: "退出专注模式",
|
exitFocusMode: "退出专注模式",
|
||||||
splitRight: "向右拆分窗格",
|
splitRight: "向右拆分窗格",
|
||||||
splitDown: "向下拆分窗格",
|
splitDown: "向下拆分窗格",
|
||||||
@@ -604,10 +604,10 @@ export const zhCN: TranslationResources = {
|
|||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
},
|
},
|
||||||
explorer: {
|
sidePanel: {
|
||||||
open: "打开 explorer",
|
open: "打开侧边面板",
|
||||||
close: "关闭 explorer",
|
close: "关闭侧边面板",
|
||||||
toggle: "切换 explorer",
|
toggle: "切换侧边面板",
|
||||||
changes: "变更",
|
changes: "变更",
|
||||||
files: "文件",
|
files: "文件",
|
||||||
},
|
},
|
||||||
@@ -706,6 +706,10 @@ export const zhCN: TranslationResources = {
|
|||||||
stopFailed: "停止 {{scriptName}} 失败",
|
stopFailed: "停止 {{scriptName}} 失败",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tree: {
|
||||||
|
showFolderTree: "显示文件夹树",
|
||||||
|
hideFolderTree: "隐藏文件夹树",
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
actions: {
|
actions: {
|
||||||
moreOptions: "更多选项",
|
moreOptions: "更多选项",
|
||||||
@@ -847,8 +851,6 @@ export const zhCN: TranslationResources = {
|
|||||||
split: "Side-by-side diff",
|
split: "Side-by-side diff",
|
||||||
switchToUnified: "切换到统一差异视图",
|
switchToUnified: "切换到统一差异视图",
|
||||||
switchToSplit: "切换到并排差异视图",
|
switchToSplit: "切换到并排差异视图",
|
||||||
showTreeView: "显示文件夹树",
|
|
||||||
hideTreeView: "隐藏文件夹树",
|
|
||||||
options: "Diff 选项",
|
options: "Diff 选项",
|
||||||
hideWhitespace: "隐藏空白差异",
|
hideWhitespace: "隐藏空白差异",
|
||||||
showWhitespace: "显示空白差异",
|
showWhitespace: "显示空白差异",
|
||||||
@@ -1894,6 +1896,10 @@ export const zhCN: TranslationResources = {
|
|||||||
description: "内置终端缓冲区保留的行数",
|
description: "内置终端缓冲区保留的行数",
|
||||||
accessibilityLabel: "终端回滚行数",
|
accessibilityLabel: "终端回滚行数",
|
||||||
},
|
},
|
||||||
|
sidePanelRouting: {
|
||||||
|
label: "在侧边面板中打开辅助标签页",
|
||||||
|
description: "文件链接、拉取请求和设置进度会在你的工作旁边打开,而不是在聚焦的窗格中",
|
||||||
|
},
|
||||||
autoExpandReasoning: {
|
autoExpandReasoning: {
|
||||||
label: "始终展开推理过程",
|
label: "始终展开推理过程",
|
||||||
description: "默认情况下完全展开 AI 的思考和推理过程",
|
description: "默认情况下完全展开 AI 的思考和推理过程",
|
||||||
@@ -2076,11 +2082,11 @@ export const zhCN: TranslationResources = {
|
|||||||
toggleCommandCenter: "切换命令中心",
|
toggleCommandCenter: "切换命令中心",
|
||||||
showKeyboardShortcuts: "显示键盘快捷键",
|
showKeyboardShortcuts: "显示键盘快捷键",
|
||||||
toggleLeftSidebar: "切换左侧边栏",
|
toggleLeftSidebar: "切换左侧边栏",
|
||||||
toggleRightSidebar: "切换右侧边栏",
|
toggleRightSidebar: "切换侧边面板",
|
||||||
toggleBothSidebars: "切换两侧边栏",
|
toggleBothSidebars: "切换两侧边栏",
|
||||||
toggleSettings: "切换设置",
|
toggleSettings: "切换设置",
|
||||||
toggleFocusMode: "切换专注模式",
|
toggleFocusMode: "切换专注模式",
|
||||||
toggleExplorerPaneMaximization: "切换资源管理器窗格最大化",
|
toggleExplorerPaneMaximization: "切换侧边面板最大化",
|
||||||
cycleTheme: "循环切换主题",
|
cycleTheme: "循环切换主题",
|
||||||
focusMessageInput: "聚焦消息输入框",
|
focusMessageInput: "聚焦消息输入框",
|
||||||
cycleAgentMode: "循环切换代理模式",
|
cycleAgentMode: "循环切换代理模式",
|
||||||
|
|||||||
@@ -953,7 +953,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
help: {
|
help: {
|
||||||
id: "toggle-right-sidebar",
|
id: "toggle-right-sidebar",
|
||||||
section: "layout",
|
section: "layout",
|
||||||
label: "Toggle right sidebar",
|
label: "Toggle side panel",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -964,7 +964,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
help: {
|
help: {
|
||||||
id: "toggle-right-sidebar",
|
id: "toggle-right-sidebar",
|
||||||
section: "layout",
|
section: "layout",
|
||||||
label: "Toggle right sidebar",
|
label: "Toggle side panel",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ import {
|
|||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
|
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||||
@@ -437,6 +437,7 @@ export function AgentConversationPanel() {
|
|||||||
|
|
||||||
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
|
resourceKey: (target) => target.agentId,
|
||||||
component: AgentConversationPanel,
|
component: AgentConversationPanel,
|
||||||
useDescriptor: useAgentPanelDescriptor,
|
useDescriptor: useAgentPanelDescriptor,
|
||||||
};
|
};
|
||||||
@@ -1592,7 +1593,7 @@ function ActiveAgentComposer({
|
|||||||
if (attachment.kind !== "review") {
|
if (attachment.kind !== "review") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openExplorerSurface({
|
openSidePanelView({
|
||||||
isCompact: isCompactFormFactor,
|
isCompact: isCompactFormFactor,
|
||||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||||
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { AgentTaskList } from "@/composer/task-list";
|
|||||||
import { ComposerTrackBar } from "@/composer/tracks";
|
import { ComposerTrackBar } from "@/composer/tracks";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|
||||||
import {
|
import {
|
||||||
type ArchiveFinishedStatus,
|
type ArchiveFinishedStatus,
|
||||||
useArchiveSubagent,
|
useArchiveSubagent,
|
||||||
@@ -15,6 +15,7 @@ import { SubagentsTrack } from "@/subagents/track";
|
|||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
|
import { openSupportingTab } from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pane's trackers — its subagents and its task list — as a row of pills above the composer.
|
* The pane's trackers — its subagents and its task list — as a row of pills above the composer.
|
||||||
@@ -39,6 +40,9 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
const { workspaceId, tabId, openTab } = usePaneContext();
|
const { workspaceId, tabId, openTab } = usePaneContext();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
||||||
|
const openInSidePanelByDefault = useSettings(
|
||||||
|
(settings) => settings.openSupportingTabsInSidePanel,
|
||||||
|
);
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
const canDetachSubagents = useSessionStore(
|
const canDetachSubagents = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||||
@@ -54,28 +58,34 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canSplit && workspaceKey) {
|
if (canSplit && workspaceKey) {
|
||||||
useWorkspaceLayoutStore.getState().openTabInExplorerPaneFocused(workspaceKey, {
|
openSupportingTab({
|
||||||
|
isCompact,
|
||||||
|
workspaceKey,
|
||||||
target: { kind: "agent", agentId: subagentId },
|
target: { kind: "agent", agentId: subagentId },
|
||||||
|
openInSidePanelByDefault,
|
||||||
parentTabId: tabId,
|
parentTabId: tabId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigateToAgent({ serverId, agentId: subagentId });
|
navigateToAgent({ serverId, agentId: subagentId });
|
||||||
},
|
},
|
||||||
[canSplit, serverId, tabId, workspaceId, workspaceKey],
|
[canSplit, isCompact, openInSidePanelByDefault, serverId, tabId, workspaceId, workspaceKey],
|
||||||
);
|
);
|
||||||
const handleOpenProviderSubagent = useCallback(
|
const handleOpenProviderSubagent = useCallback(
|
||||||
(parentAgentId: string, subagentId: string) => {
|
(parentAgentId: string, subagentId: string) => {
|
||||||
if (canSplit && workspaceKey) {
|
if (canSplit && workspaceKey) {
|
||||||
useWorkspaceLayoutStore.getState().openTabInExplorerPaneFocused(workspaceKey, {
|
openSupportingTab({
|
||||||
|
isCompact,
|
||||||
|
workspaceKey,
|
||||||
target: { kind: "provider_subagent", parentAgentId, subagentId },
|
target: { kind: "provider_subagent", parentAgentId, subagentId },
|
||||||
|
openInSidePanelByDefault,
|
||||||
parentTabId: tabId,
|
parentTabId: tabId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||||
},
|
},
|
||||||
[canSplit, openTab, tabId, workspaceKey],
|
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
|
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const changesStateSchema = z.strictObject({
|
||||||
|
mode: z.enum(["uncommitted", "base"]),
|
||||||
|
baseRef: z.string().optional(),
|
||||||
|
layout: z.enum(["unified", "split"]),
|
||||||
|
wrapLines: z.boolean(),
|
||||||
|
hideWhitespace: z.boolean(),
|
||||||
|
treeVisible: z.boolean(),
|
||||||
|
treeWidth: z.number().optional(),
|
||||||
|
collapsedFilePaths: z.array(z.string()),
|
||||||
|
collapsedFolderPaths: z.array(z.string()),
|
||||||
|
commitsCollapsed: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangesState = z.infer<typeof changesStateSchema>;
|
||||||
|
|
||||||
|
export const defaultChangesState: ChangesState = {
|
||||||
|
mode: "uncommitted",
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: false,
|
||||||
|
hideWhitespace: false,
|
||||||
|
treeVisible: false,
|
||||||
|
collapsedFilePaths: [],
|
||||||
|
collapsedFolderPaths: [],
|
||||||
|
commitsCollapsed: true,
|
||||||
|
};
|
||||||
@@ -5,7 +5,8 @@ import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { DiffDocument } from "@/git/diff-document";
|
import { DiffDocument } from "@/git/diff-document";
|
||||||
import { ChangesSurface, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane";
|
import { ChangesSurface, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane";
|
||||||
@@ -17,6 +18,8 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
|
|||||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
import { defaultChangesState, changesStateSchema } from "@/panels/changes/state";
|
||||||
|
import { usePanelState } from "@/panels/use-panel-state";
|
||||||
|
|
||||||
const ThemedFileDiff = withUnistyles(FileDiff);
|
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||||
@@ -75,6 +78,7 @@ function PanelState({
|
|||||||
function WorkingDiffPanel() {
|
function WorkingDiffPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||||
|
const [changesState, setChangesState] = usePanelState(changesStateSchema, defaultChangesState);
|
||||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const isActive = useRetainedPanelActive();
|
const isActive = useRetainedPanelActive();
|
||||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
@@ -101,6 +105,8 @@ function WorkingDiffPanel() {
|
|||||||
focusRequestId={target.focusRequestId}
|
focusRequestId={target.focusRequestId}
|
||||||
onOpenFile={handleOpenFile}
|
onOpenFile={handleOpenFile}
|
||||||
onAddToChat={canAddToChat ? addFile : undefined}
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
state={changesState}
|
||||||
|
onStateChange={setChangesState}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -151,7 +157,7 @@ function CommitDiffPanel() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="commit-diff-panel">
|
<View style={styles.container} testID="commit-diff-panel">
|
||||||
{panelPreferences.canUseSplitLayout ? (
|
{panelPreferences.canUseSplitLayout ? (
|
||||||
<View style={styles.toolbar}>
|
<PaneContentToolbar style={styles.toolbar} testID="commit-diff-header">
|
||||||
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
|
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
|
||||||
<DiffLayoutToggle
|
<DiffLayoutToggle
|
||||||
layout={panelPreferences.preferences.layout}
|
layout={panelPreferences.preferences.layout}
|
||||||
@@ -160,7 +166,7 @@ function CommitDiffPanel() {
|
|||||||
onToggle={panelPreferences.toggleLayout}
|
onToggle={panelPreferences.toggleLayout}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</PaneContentToolbar>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.body}>{body}</View>
|
<View style={styles.body}>{body}</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -197,12 +203,14 @@ export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = {
|
|||||||
kind: "working_diff",
|
kind: "working_diff",
|
||||||
component: WorkingDiffPanel,
|
component: WorkingDiffPanel,
|
||||||
useDescriptor: useWorkingDiffPanelDescriptor,
|
useDescriptor: useWorkingDiffPanelDescriptor,
|
||||||
|
resourceKey: () => "working_diff",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
||||||
kind: "commit_diff",
|
kind: "commit_diff",
|
||||||
component: CommitDiffPanel,
|
component: CommitDiffPanel,
|
||||||
useDescriptor: useCommitDiffPanelDescriptor,
|
useDescriptor: useCommitDiffPanelDescriptor,
|
||||||
|
resourceKey: (target) => target.sha,
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -211,15 +219,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
},
|
||||||
toolbarActions: {
|
toolbarActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PanelRegistration } from "@/panels/panel-registry";
|
|||||||
|
|
||||||
export const draftPanelRegistration: PanelRegistration<"draft"> = {
|
export const draftPanelRegistration: PanelRegistration<"draft"> = {
|
||||||
kind: "draft",
|
kind: "draft",
|
||||||
|
resourceKey: (target) => target.draftId,
|
||||||
component: AgentConversationPanel,
|
component: AgentConversationPanel,
|
||||||
useDescriptor: useDraftPanelDescriptor,
|
useDescriptor: useDraftPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { createMaterialFileIcon } from "@/components/material-file-icon";
|
|||||||
import { FileExplorerPane } from "@/components/file-explorer-pane";
|
import { FileExplorerPane } from "@/components/file-explorer-pane";
|
||||||
import { TreeRail } from "@/components/tree-rail";
|
import { TreeRail } from "@/components/tree-rail";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { defaultFileState, fileStateSchema } from "@/panels/file/state";
|
||||||
|
import { usePanelState } from "@/panels/use-panel-state";
|
||||||
|
|
||||||
const CENTERED_PADDED_STYLE = {
|
const CENTERED_PADDED_STYLE = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -35,12 +37,18 @@ function FilePanel() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverId, workspaceId, target, fileNavigationRevision, retargetCurrentTab } =
|
const { serverId, workspaceId, target, fileNavigationRevision, retargetCurrentTab } =
|
||||||
usePaneContext();
|
usePaneContext();
|
||||||
|
const [fileState, setFileState] = usePanelState(fileStateSchema, defaultFileState);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const treeVisible = fileState.treeVisible;
|
||||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const handleOpenFile = useCallback(
|
const handleOpenFile = useCallback(
|
||||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||||
[retargetCurrentTab],
|
[retargetCurrentTab],
|
||||||
);
|
);
|
||||||
|
const handleTreeWidthChange = useCallback(
|
||||||
|
(treeWidth: number) => setFileState({ ...fileState, treeWidth }),
|
||||||
|
[fileState, setFileState],
|
||||||
|
);
|
||||||
invariant(target.kind === "file", "FilePanel requires file target");
|
invariant(target.kind === "file", "FilePanel requires file target");
|
||||||
if (!workspaceDirectory) {
|
if (!workspaceDirectory) {
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +57,9 @@ function FilePanel() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isCompact) {
|
// Branch around the whole rail rather than making the tree slot conditional —
|
||||||
|
// see the `TreeRail` children contract.
|
||||||
|
if (isCompact || !treeVisible) {
|
||||||
return (
|
return (
|
||||||
<FilePane
|
<FilePane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -60,7 +70,12 @@ function FilePanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TreeRail testID="file-tree-rail">
|
<TreeRail
|
||||||
|
testID="file-tree-rail"
|
||||||
|
width={fileState.treeWidth ?? 220}
|
||||||
|
onWidthChange={handleTreeWidthChange}
|
||||||
|
minimumWidth={160}
|
||||||
|
>
|
||||||
<FilePane
|
<FilePane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceRoot={workspaceDirectory}
|
workspaceRoot={workspaceDirectory}
|
||||||
@@ -81,4 +96,5 @@ export const filePanelRegistration: PanelRegistration<"file"> = {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
component: FilePanel,
|
component: FilePanel,
|
||||||
useDescriptor: useFilePanelDescriptor,
|
useDescriptor: useFilePanelDescriptor,
|
||||||
|
resourceKey: (target) => target.path,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const fileStateSchema = z.strictObject({
|
||||||
|
treeVisible: z.boolean(),
|
||||||
|
treeWidth: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FileState = z.infer<typeof fileStateSchema>;
|
||||||
|
|
||||||
|
export const defaultFileState: FileState = { treeVisible: false };
|
||||||
|
|
||||||
|
export const fileStateForFilesView: FileState = { treeVisible: true };
|
||||||
@@ -60,6 +60,7 @@ function FilesPanel() {
|
|||||||
|
|
||||||
export const filesPanelRegistration: PanelRegistration<"files"> = {
|
export const filesPanelRegistration: PanelRegistration<"files"> = {
|
||||||
kind: "files",
|
kind: "files",
|
||||||
|
resourceKey: () => "files",
|
||||||
component: FilesPanel,
|
component: FilesPanel,
|
||||||
useDescriptor: useFilesPanelDescriptor,
|
useDescriptor: useFilesPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, type ReactNode } from "react";
|
import React, { createContext, useContext, type ReactNode } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
|
|
||||||
@@ -8,10 +9,12 @@ export interface PaneContextValue {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
|
state?: JsonValue;
|
||||||
fileNavigationRevision?: number;
|
fileNavigationRevision?: number;
|
||||||
openTab: (target: WorkspaceTabTarget) => void;
|
openTab: (target: WorkspaceTabTarget) => void;
|
||||||
closeCurrentTab: () => void;
|
closeCurrentTab: () => void;
|
||||||
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
|
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
|
||||||
|
setCurrentTabState: (state: JsonValue) => void;
|
||||||
openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
|
openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
|
||||||
openImportSheet: () => void;
|
openImportSheet: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface PanelRegistration<
|
|||||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||||
context: PanelDescriptorContext,
|
context: PanelDescriptorContext,
|
||||||
): PanelDescriptor;
|
): PanelDescriptor;
|
||||||
|
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
|
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = {
|
export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = {
|
||||||
kind: "provider_subagent",
|
kind: "provider_subagent",
|
||||||
|
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||||
component: ProviderSubagentPanel,
|
component: ProviderSubagentPanel,
|
||||||
useDescriptor: useProviderSubagentDescriptor,
|
useDescriptor: useProviderSubagentDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function PullRequestPanel() {
|
|||||||
|
|
||||||
export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = {
|
export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = {
|
||||||
kind: "pull_request",
|
kind: "pull_request",
|
||||||
|
resourceKey: () => "pull_request",
|
||||||
component: PullRequestPanel,
|
component: PullRequestPanel,
|
||||||
useDescriptor: usePullRequestPanelDescriptor,
|
useDescriptor: usePullRequestPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
@@ -13,29 +13,24 @@ import {
|
|||||||
} from "@/git/pull-request-panel";
|
} from "@/git/pull-request-panel";
|
||||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
|
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
|
||||||
|
|
||||||
export function usePullRequestAutoAdd(input: {
|
/**
|
||||||
workspaceKey: string | null;
|
* Whether this workspace's branch has a pull request. Detection only offers the
|
||||||
|
* pull request as something the user can open; it never opens a tab on its own.
|
||||||
|
*/
|
||||||
|
export function useHasPullRequest(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}): void {
|
}): boolean {
|
||||||
const status = useCheckoutPrStatusQuery({
|
const status = useCheckoutPrStatusQuery({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
cwd: input.cwd || "",
|
cwd: input.cwd || "",
|
||||||
enabled: input.enabled,
|
enabled: input.enabled,
|
||||||
}).status;
|
}).status;
|
||||||
const observePullRequest = useWorkspaceLayoutStore((state) => state.observePullRequest);
|
return getPullRequestIdentity(status) !== null;
|
||||||
const identity = getPullRequestIdentity(status);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (input.workspaceKey) {
|
|
||||||
observePullRequest(input.workspaceKey, identity);
|
|
||||||
}
|
|
||||||
}, [identity, input.workspaceKey, observePullRequest]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PullRequestContent(input: {
|
export function PullRequestContent(input: {
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ function SetupCommandRow({
|
|||||||
|
|
||||||
export const setupPanelRegistration: PanelRegistration<"setup"> = {
|
export const setupPanelRegistration: PanelRegistration<"setup"> = {
|
||||||
kind: "setup",
|
kind: "setup",
|
||||||
|
resourceKey: (target) => target.workspaceId,
|
||||||
component: SetupPanel,
|
component: SetupPanel,
|
||||||
useDescriptor: useSetupPanelDescriptor,
|
useDescriptor: useSetupPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ function TerminalPanel() {
|
|||||||
|
|
||||||
export const terminalPanelRegistration: PanelRegistration<"terminal"> = {
|
export const terminalPanelRegistration: PanelRegistration<"terminal"> = {
|
||||||
kind: "terminal",
|
kind: "terminal",
|
||||||
|
resourceKey: (target) => target.terminalId,
|
||||||
component: TerminalPanel,
|
component: TerminalPanel,
|
||||||
useDescriptor: useTerminalPanelDescriptor,
|
useDescriptor: useTerminalPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
|
||||||
|
export function usePanelState<T extends JsonValue>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
defaultState: T,
|
||||||
|
): [T, (state: T) => void] {
|
||||||
|
const { state, setCurrentTabState } = usePaneContext();
|
||||||
|
const parsed = useMemo(() => schema.safeParse(state), [schema, state]);
|
||||||
|
const currentState = parsed.success ? parsed.data : defaultState;
|
||||||
|
const setState = useCallback(
|
||||||
|
(nextState: T) => setCurrentTabState(nextState),
|
||||||
|
[setCurrentTabState],
|
||||||
|
);
|
||||||
|
return [currentState, setState];
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ function usePluginPanelDescriptor(
|
|||||||
|
|
||||||
export const pluginPanelRegistration: PanelRegistration<"plugin"> = {
|
export const pluginPanelRegistration: PanelRegistration<"plugin"> = {
|
||||||
kind: "plugin",
|
kind: "plugin",
|
||||||
|
resourceKey: (target) => `${target.pluginId}:${target.panelId}:${target.context}`,
|
||||||
component: PluginPanel,
|
component: PluginPanel,
|
||||||
useDescriptor: usePluginPanelDescriptor,
|
useDescriptor: usePluginPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-
|
|||||||
import { EditorSection } from "@/screens/settings/editor-section";
|
import { EditorSection } from "@/screens/settings/editor-section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { supportsDesktopPaneSplits } from "@/constants/layout";
|
||||||
import { CommunityLinks } from "@/components/community-links";
|
import { CommunityLinks } from "@/components/community-links";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
@@ -273,6 +274,7 @@ interface GeneralSectionProps {
|
|||||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||||
handleLanguageChange: (language: AppLanguage) => void;
|
handleLanguageChange: (language: AppLanguage) => void;
|
||||||
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||||
|
handleSidePanelRoutingChange: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceUrlBehaviorMenuItemProps {
|
interface ServiceUrlBehaviorMenuItemProps {
|
||||||
@@ -329,6 +331,7 @@ function GeneralSection({
|
|||||||
handleServiceUrlBehaviorChange,
|
handleServiceUrlBehaviorChange,
|
||||||
handleLanguageChange,
|
handleLanguageChange,
|
||||||
handleTerminalScrollbackLinesChange,
|
handleTerminalScrollbackLinesChange,
|
||||||
|
handleSidePanelRoutingChange,
|
||||||
}: GeneralSectionProps) {
|
}: GeneralSectionProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const activeLocale = getActiveLocale(i18n.language);
|
const activeLocale = getActiveLocale(i18n.language);
|
||||||
@@ -459,6 +462,23 @@ function GeneralSection({
|
|||||||
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
{supportsDesktopPaneSplits() ? (
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>
|
||||||
|
{t("settings.general.sidePanelRouting.label")}
|
||||||
|
</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
{t("settings.general.sidePanelRouting.description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.openSupportingTabsInSidePanel}
|
||||||
|
onValueChange={handleSidePanelRoutingChange}
|
||||||
|
accessibilityLabel={t("settings.general.sidePanelRouting.label")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
@@ -1211,6 +1231,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSidePanelRoutingChange = useCallback(
|
||||||
|
(openSupportingTabsInSidePanel: boolean) => {
|
||||||
|
void updateSettings({ openSupportingTabsInSidePanel });
|
||||||
|
},
|
||||||
|
[updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const handleUseLegacyTerminalRendererChange = useCallback(
|
const handleUseLegacyTerminalRendererChange = useCallback(
|
||||||
(useLegacyTerminalRenderer: boolean) => {
|
(useLegacyTerminalRenderer: boolean) => {
|
||||||
void updateSettings({ useLegacyTerminalRenderer });
|
void updateSettings({ useLegacyTerminalRenderer });
|
||||||
@@ -1420,6 +1447,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||||
handleLanguageChange={handleLanguageChange}
|
handleLanguageChange={handleLanguageChange}
|
||||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||||
|
handleSidePanelRoutingChange={handleSidePanelRoutingChange}
|
||||||
/>
|
/>
|
||||||
{isDesktopApp ? <BrowserDataSection /> : null}
|
{isDesktopApp ? <BrowserDataSection /> : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ import {
|
|||||||
type WorkspaceTabMenuLabels,
|
type WorkspaceTabMenuLabels,
|
||||||
} from "@/screens/workspace/workspace-tab-menu";
|
} from "@/screens/workspace/workspace-tab-menu";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
|
||||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
@@ -1183,7 +1185,15 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
});
|
});
|
||||||
const openTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||||
|
const openNewTab = useCallback(
|
||||||
|
(
|
||||||
|
requestedWorkspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
placement?: WorkspaceTabPlacement,
|
||||||
|
) => openTab({ workspaceKey: requestedWorkspaceKey, target, intent: "new", placement }),
|
||||||
|
[openTab],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
updateMeasuredWidth(setTabsContainerWidth, event);
|
updateMeasuredWidth(setTabsContainerWidth, event);
|
||||||
@@ -1425,9 +1435,11 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openTabFocused(workspaceKey, target, { paneId });
|
// Picked from this pane's own menu, so the tab belongs here even if it is
|
||||||
|
// currently open somewhere else.
|
||||||
|
openNewTab(workspaceKey, target, paneId ? { mode: "pane", paneId } : undefined);
|
||||||
},
|
},
|
||||||
[openTabFocused, paneId, workspaceKey],
|
[openNewTab, paneId, workspaceKey],
|
||||||
);
|
);
|
||||||
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
|
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
|
||||||
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
|
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import {
|
|||||||
createWorkspaceFileTabTarget,
|
createWorkspaceFileTabTarget,
|
||||||
normalizeWorkspaceFileLocation,
|
normalizeWorkspaceFileLocation,
|
||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
|
import {
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
type WorkspaceTabPlacement,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
interface OpenWorkspaceFileFromExplorerInput {
|
interface OpenWorkspaceFileFromExplorerInput {
|
||||||
@@ -11,6 +15,7 @@ interface OpenWorkspaceFileFromExplorerInput {
|
|||||||
openWorkspaceTabInFocusedPane: (
|
openWorkspaceTabInFocusedPane: (
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
|
placement?: WorkspaceTabPlacement,
|
||||||
) => string | null;
|
) => string | null;
|
||||||
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
|
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -27,6 +32,7 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor
|
|||||||
const tabId = input.openWorkspaceTabInFocusedPane(
|
const tabId = input.openWorkspaceTabInFocusedPane(
|
||||||
input.persistenceKey,
|
input.persistenceKey,
|
||||||
createWorkspaceFileTabTarget(location),
|
createWorkspaceFileTabTarget(location),
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
);
|
);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function buildContent(tab: WorkspaceTabDescriptor = agentTab) {
|
|||||||
onOpenTab: vi.fn(),
|
onOpenTab: vi.fn(),
|
||||||
onCloseCurrentTab: vi.fn(),
|
onCloseCurrentTab: vi.fn(),
|
||||||
onRetargetCurrentTab: vi.fn(),
|
onRetargetCurrentTab: vi.fn(),
|
||||||
|
onSetCurrentTabState: vi.fn(),
|
||||||
onOpenWorkspaceFile: vi.fn(),
|
onOpenWorkspaceFile: vi.fn(),
|
||||||
onOpenImportSheet: vi.fn(),
|
onOpenImportSheet: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface BuildWorkspacePaneContentModelInput {
|
|||||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
onCloseCurrentTab: () => void;
|
onCloseCurrentTab: () => void;
|
||||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
|
onSetCurrentTabState: (state: WorkspaceTabDescriptor["state"]) => void;
|
||||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||||
onOpenImportSheet: () => void;
|
onOpenImportSheet: () => void;
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ export function buildWorkspacePaneContentModel({
|
|||||||
onOpenTab,
|
onOpenTab,
|
||||||
onCloseCurrentTab,
|
onCloseCurrentTab,
|
||||||
onRetargetCurrentTab,
|
onRetargetCurrentTab,
|
||||||
|
onSetCurrentTabState,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
onOpenImportSheet,
|
onOpenImportSheet,
|
||||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||||
@@ -53,10 +55,12 @@ export function buildWorkspacePaneContentModel({
|
|||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
target: tab.target,
|
target: tab.target,
|
||||||
|
state: tab.state,
|
||||||
fileNavigationRevision,
|
fileNavigationRevision,
|
||||||
openTab: onOpenTab,
|
openTab: onOpenTab,
|
||||||
closeCurrentTab: onCloseCurrentTab,
|
closeCurrentTab: onCloseCurrentTab,
|
||||||
retargetCurrentTab: onRetargetCurrentTab,
|
retargetCurrentTab: onRetargetCurrentTab,
|
||||||
|
setCurrentTabState: onSetCurrentTabState,
|
||||||
openFileInWorkspace: onOpenWorkspaceFile,
|
openFileInWorkspace: onOpenWorkspaceFile,
|
||||||
openImportSheet: onOpenImportSheet,
|
openImportSheet: onOpenImportSheet,
|
||||||
},
|
},
|
||||||
@@ -80,6 +84,7 @@ export function WorkspacePaneContent({
|
|||||||
const openTab = useStableEvent(paneContextValue.openTab);
|
const openTab = useStableEvent(paneContextValue.openTab);
|
||||||
const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab);
|
const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab);
|
||||||
const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab);
|
const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab);
|
||||||
|
const setCurrentTabState = useStableEvent(paneContextValue.setCurrentTabState);
|
||||||
const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace);
|
const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace);
|
||||||
const openImportSheet = useStableEvent(paneContextValue.openImportSheet);
|
const openImportSheet = useStableEvent(paneContextValue.openImportSheet);
|
||||||
const stablePaneContextValue = useMemo(
|
const stablePaneContextValue = useMemo(
|
||||||
@@ -88,10 +93,12 @@ export function WorkspacePaneContent({
|
|||||||
workspaceId: paneContextValue.workspaceId,
|
workspaceId: paneContextValue.workspaceId,
|
||||||
tabId: paneContextValue.tabId,
|
tabId: paneContextValue.tabId,
|
||||||
target: paneContextValue.target,
|
target: paneContextValue.target,
|
||||||
|
state: paneContextValue.state,
|
||||||
fileNavigationRevision: paneContextValue.fileNavigationRevision,
|
fileNavigationRevision: paneContextValue.fileNavigationRevision,
|
||||||
openTab,
|
openTab,
|
||||||
closeCurrentTab,
|
closeCurrentTab,
|
||||||
retargetCurrentTab,
|
retargetCurrentTab,
|
||||||
|
setCurrentTabState,
|
||||||
openFileInWorkspace,
|
openFileInWorkspace,
|
||||||
openImportSheet,
|
openImportSheet,
|
||||||
}),
|
}),
|
||||||
@@ -104,8 +111,10 @@ export function WorkspacePaneContent({
|
|||||||
paneContextValue.fileNavigationRevision,
|
paneContextValue.fileNavigationRevision,
|
||||||
paneContextValue.tabId,
|
paneContextValue.tabId,
|
||||||
paneContextValue.target,
|
paneContextValue.target,
|
||||||
|
paneContextValue.state,
|
||||||
paneContextValue.workspaceId,
|
paneContextValue.workspaceId,
|
||||||
retargetCurrentTab,
|
retargetCurrentTab,
|
||||||
|
setCurrentTabState,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const paneFocusValue = useMemo(
|
const paneFocusValue = useMemo(
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { memo, useCallback, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
FileDiff,
|
||||||
|
FolderTree,
|
||||||
|
GitPullRequest,
|
||||||
|
SquarePen,
|
||||||
|
SquareTerminal,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
|
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||||
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||||
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
|
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||||
|
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||||
|
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
const FILES_ICON = <ThemedFolderTree size={16} uniProps={mutedColorMapping} />;
|
||||||
|
const CHANGES_ICON = <ThemedFileDiff size={16} uniProps={mutedColorMapping} />;
|
||||||
|
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={16} uniProps={mutedColorMapping} />;
|
||||||
|
const AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
|
||||||
|
const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
|
||||||
|
|
||||||
|
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
|
||||||
|
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
||||||
|
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
|
||||||
|
|
||||||
|
/** The launcher's own binding, or nothing when the action has no shortcut. */
|
||||||
|
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
||||||
|
const keys = useShortcutKeys(actionId);
|
||||||
|
return keys ? <Shortcut chord={keys} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
|
||||||
|
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One launcher. This is a row with a trailing slot, not a `<Button>` — the shortcut
|
||||||
|
* badge sits opposite the label on a shared rail, which is the list-row anatomy in
|
||||||
|
* `docs/design.md` §12 rather than anything the button primitive can express.
|
||||||
|
*/
|
||||||
|
function LauncherRow({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onPress: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
onPress={onPress}
|
||||||
|
style={getLauncherRowStyle}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<View style={styles.rowSpacer} />
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePaneEmptyStateProps {
|
||||||
|
paneId: string;
|
||||||
|
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
|
||||||
|
showChanges: boolean;
|
||||||
|
showPullRequest: boolean;
|
||||||
|
onOpenView: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
|
||||||
|
onCreateAgent: (input: { paneId: string }) => void;
|
||||||
|
onCreateTerminal: (input: { paneId: string }) => void;
|
||||||
|
/**
|
||||||
|
* Dismisses the pane. Absent on the last pane standing, where there is nothing
|
||||||
|
* for the layout to fall back to. What "close" means to this pane — hiding the
|
||||||
|
* Side panel, removing an ordinary split — belongs to the layout store.
|
||||||
|
*/
|
||||||
|
onClosePane: (() => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
||||||
|
paneId,
|
||||||
|
showChanges,
|
||||||
|
showPullRequest,
|
||||||
|
onOpenView,
|
||||||
|
onCreateAgent,
|
||||||
|
onCreateTerminal,
|
||||||
|
onClosePane,
|
||||||
|
}: WorkspacePaneEmptyStateProps): ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const openFiles = useCallback(
|
||||||
|
() => onOpenView({ paneId, target: FILES_TARGET }),
|
||||||
|
[onOpenView, paneId],
|
||||||
|
);
|
||||||
|
const openChanges = useCallback(
|
||||||
|
() => onOpenView({ paneId, target: CHANGES_TARGET }),
|
||||||
|
[onOpenView, paneId],
|
||||||
|
);
|
||||||
|
const openPullRequest = useCallback(
|
||||||
|
() => onOpenView({ paneId, target: PULL_REQUEST_TARGET }),
|
||||||
|
[onOpenView, paneId],
|
||||||
|
);
|
||||||
|
const createAgent = useCallback(() => onCreateAgent({ paneId }), [onCreateAgent, paneId]);
|
||||||
|
const createTerminal = useCallback(
|
||||||
|
() => onCreateTerminal({ paneId }),
|
||||||
|
[onCreateTerminal, paneId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-pane-empty-state">
|
||||||
|
<View style={styles.rail}>
|
||||||
|
<View style={styles.stack}>
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.files")}
|
||||||
|
icon={FILES_ICON}
|
||||||
|
onPress={openFiles}
|
||||||
|
>
|
||||||
|
<LauncherShortcut actionId="workspace-tab-target-files" />
|
||||||
|
</LauncherRow>
|
||||||
|
{showChanges ? (
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.changes")}
|
||||||
|
icon={CHANGES_ICON}
|
||||||
|
onPress={openChanges}
|
||||||
|
>
|
||||||
|
<LauncherShortcut actionId="workspace-tab-target-changes" />
|
||||||
|
</LauncherRow>
|
||||||
|
) : null}
|
||||||
|
{showPullRequest ? (
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.pullRequest")}
|
||||||
|
icon={PULL_REQUEST_ICON}
|
||||||
|
onPress={openPullRequest}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.newAgent")}
|
||||||
|
icon={AGENT_ICON}
|
||||||
|
onPress={createAgent}
|
||||||
|
>
|
||||||
|
<LauncherShortcut actionId="workspace-tab-target-agent" />
|
||||||
|
</LauncherRow>
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.newTerminal")}
|
||||||
|
icon={TERMINAL_ICON}
|
||||||
|
onPress={createTerminal}
|
||||||
|
>
|
||||||
|
<LauncherShortcut actionId="workspace-terminal-new" />
|
||||||
|
</LauncherRow>
|
||||||
|
</View>
|
||||||
|
{onClosePane ? (
|
||||||
|
<Button variant="ghost" size="sm" style={styles.closePane} onPress={onClosePane}>
|
||||||
|
{t("workspace.tabs.actions.closePane")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wide enough for a shortcut badge to sit clear of the longest label, narrow enough
|
||||||
|
// that the stack still reads as one column in a half-width pane.
|
||||||
|
const LAUNCHER_RAIL_WIDTH = 380;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: LAUNCHER_RAIL_WIDTH,
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
stack: {
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
minHeight: 44,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
rowHovered: {
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
rowLabel: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
rowSpacer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closePane: {
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -58,6 +58,7 @@ function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
|
|||||||
tabId,
|
tabId,
|
||||||
target,
|
target,
|
||||||
createdAt: tab.createdAt,
|
createdAt: tab.createdAt,
|
||||||
|
state: tab.state,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ function normalizeWorkspacePaneTabs(tabs: WorkspaceTab[]): NormalizeWorkspacePan
|
|||||||
tabId: normalizedTab.tabId,
|
tabId: normalizedTab.tabId,
|
||||||
kind: normalizedTab.target.kind,
|
kind: normalizedTab.target.kind,
|
||||||
target: normalizedTab.target,
|
target: normalizedTab.target,
|
||||||
|
state: normalizedTab.state,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@@ -47,20 +48,22 @@ import { getOrCreateClientId } from "@/utils/client-id";
|
|||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import {
|
||||||
isExplorerSurfaceOpen,
|
isSidePanelOpen,
|
||||||
openWorkspaceTabBeside,
|
openSupportingTab,
|
||||||
openWorkspaceTabBesideInBackground,
|
toggleSidePanel,
|
||||||
toggleExplorerSurface,
|
useIsSidePanelOpen,
|
||||||
useIsExplorerSurfaceOpen,
|
} from "@/workspace-tabs/side-panel";
|
||||||
} from "@/workspace-tabs/explorer-surface";
|
|
||||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||||
import { traceInstant } from "@/performance/native-trace";
|
import { traceInstant } from "@/performance/native-trace";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
|
canDismissPaneInLayout,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
getFocusedBrowserId,
|
getFocusedBrowserId,
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
type WorkspaceTabOpenOptions,
|
type WorkspaceTabPlacement,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
useWorkspaceLayoutStoreHydrated,
|
useWorkspaceLayoutStoreHydrated,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
@@ -69,6 +72,7 @@ import {
|
|||||||
type WorkspaceTab,
|
type WorkspaceTab,
|
||||||
type WorkspaceTabTarget,
|
type WorkspaceTabTarget,
|
||||||
} from "@/workspace-tabs/model";
|
} from "@/workspace-tabs/model";
|
||||||
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
@@ -112,6 +116,7 @@ import {
|
|||||||
WorkspaceTabRenameModal,
|
WorkspaceTabRenameModal,
|
||||||
} from "@/screens/workspace/use-workspace-tab-rename";
|
} from "@/screens/workspace/use-workspace-tab-rename";
|
||||||
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
|
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
|
||||||
|
import { WorkspacePaneEmptyState } from "@/screens/workspace/workspace-pane-empty-state";
|
||||||
import {
|
import {
|
||||||
WorkspaceDesktopTabsRow,
|
WorkspaceDesktopTabsRow,
|
||||||
type WorkspaceDesktopTabRowItem,
|
type WorkspaceDesktopTabRowItem,
|
||||||
@@ -177,7 +182,8 @@ import {
|
|||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||||
import { usePullRequestAutoAdd } from "@/panels/pull-request";
|
import { useHasPullRequest } from "@/panels/pull-request";
|
||||||
|
import { fileStateForFilesView } from "@/panels/file/state";
|
||||||
|
|
||||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||||
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
||||||
@@ -797,6 +803,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
|
|||||||
cachedDescriptor &&
|
cachedDescriptor &&
|
||||||
cachedDescriptor.key === tabDescriptor.key &&
|
cachedDescriptor.key === tabDescriptor.key &&
|
||||||
cachedDescriptor.kind === tabDescriptor.kind &&
|
cachedDescriptor.kind === tabDescriptor.kind &&
|
||||||
|
cachedDescriptor.state === tabDescriptor.state &&
|
||||||
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
|
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
|
||||||
) {
|
) {
|
||||||
next.set(tabDescriptor.tabId, cachedDescriptor);
|
next.set(tabDescriptor.tabId, cachedDescriptor);
|
||||||
@@ -1317,7 +1324,15 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
|
|||||||
return `${serverId}:${workspaceId}`;
|
return `${serverId}:${workspaceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canObservePullRequest(
|
/**
|
||||||
|
* A pane the user acted inside owns the tab: it opens there, and an existing tab
|
||||||
|
* moves there. No pane means the open has no opinion beyond the focused pane.
|
||||||
|
*/
|
||||||
|
function paneLocalPlacement(paneId: string | null | undefined): WorkspaceTabPlacement {
|
||||||
|
return paneId ? { mode: "pane", paneId } : FOCUSED_PANE_PLACEMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDetectPullRequest(
|
||||||
isRouteFocused: boolean,
|
isRouteFocused: boolean,
|
||||||
isGitCheckout: boolean,
|
isGitCheckout: boolean,
|
||||||
isCompact: boolean,
|
isCompact: boolean,
|
||||||
@@ -1330,11 +1345,7 @@ interface WorkspaceTerminalTabActionsInput {
|
|||||||
openWorkspaceTabFocused: (
|
openWorkspaceTabFocused: (
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
options?: WorkspaceTabOpenOptions,
|
placement?: WorkspaceTabPlacement,
|
||||||
) => string | null;
|
|
||||||
openWorkspaceTabInFocusedPane: (
|
|
||||||
workspaceKey: string,
|
|
||||||
target: WorkspaceTabTarget,
|
|
||||||
) => string | null;
|
) => string | null;
|
||||||
labels: {
|
labels: {
|
||||||
workspacePathUnavailable: string;
|
workspacePathUnavailable: string;
|
||||||
@@ -1357,7 +1368,6 @@ interface WorkspaceTerminalTabActions {
|
|||||||
function useWorkspaceTerminalTabActions({
|
function useWorkspaceTerminalTabActions({
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
openWorkspaceTabFocused,
|
openWorkspaceTabFocused,
|
||||||
openWorkspaceTabInFocusedPane,
|
|
||||||
labels,
|
labels,
|
||||||
toast,
|
toast,
|
||||||
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
|
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
|
||||||
@@ -1366,22 +1376,26 @@ function useWorkspaceTerminalTabActions({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (paneId) {
|
openWorkspaceTabFocused(
|
||||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }, { paneId });
|
persistenceKey,
|
||||||
} else {
|
{ kind: "terminal", terminalId },
|
||||||
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "terminal", terminalId });
|
paneLocalPlacement(paneId),
|
||||||
}
|
);
|
||||||
},
|
},
|
||||||
[openWorkspaceTabFocused, openWorkspaceTabInFocusedPane, persistenceKey],
|
[openWorkspaceTabFocused, persistenceKey],
|
||||||
);
|
);
|
||||||
const handleScriptTerminalSelected = useCallback(
|
const handleScriptTerminalSelected = useCallback(
|
||||||
(terminalId: string) => {
|
(terminalId: string) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "terminal", terminalId });
|
openWorkspaceTabFocused(
|
||||||
|
persistenceKey,
|
||||||
|
{ kind: "terminal", terminalId },
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[openWorkspaceTabInFocusedPane, persistenceKey],
|
[openWorkspaceTabFocused, persistenceKey],
|
||||||
);
|
);
|
||||||
const handleWorkspacePathUnavailable = useCallback(() => {
|
const handleWorkspacePathUnavailable = useCallback(() => {
|
||||||
toast.error(labels.workspacePathUnavailable);
|
toast.error(labels.workspacePathUnavailable);
|
||||||
@@ -1482,15 +1496,29 @@ function WorkspaceScreenContent({
|
|||||||
}),
|
}),
|
||||||
[normalizedServerId, normalizedWorkspaceId],
|
[normalizedServerId, normalizedWorkspaceId],
|
||||||
);
|
);
|
||||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||||
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
|
const openWorkspaceTabFocused = useCallback(
|
||||||
(state) => state.openTabInFocusedPane,
|
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
|
||||||
|
openTab({ workspaceKey, target, intent: "reveal", placement }),
|
||||||
|
[openTab],
|
||||||
);
|
);
|
||||||
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
|
const createWorkspaceTab = useCallback(
|
||||||
(state) => state.openChildTabFocused,
|
(
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
placement?: WorkspaceTabPlacement,
|
||||||
|
stateValue?: JsonValue,
|
||||||
|
) => openTab({ workspaceKey, target, intent: "new", placement, state: stateValue }),
|
||||||
|
[openTab],
|
||||||
);
|
);
|
||||||
const openWorkspaceChildTabInFocusedPane = useWorkspaceLayoutStore(
|
const revealWorkspaceChildTab = useCallback(
|
||||||
(state) => state.openChildTabInFocusedPane,
|
(
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
parentTabId: string,
|
||||||
|
placement?: WorkspaceTabPlacement,
|
||||||
|
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
|
||||||
|
[openTab],
|
||||||
);
|
);
|
||||||
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
|
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
|
||||||
// requests separate so clicking an unchanged path:line can still recenter the pane.
|
// requests separate so clicking an unchanged path:line can still recenter the pane.
|
||||||
@@ -1538,7 +1566,6 @@ function WorkspaceScreenContent({
|
|||||||
} = useWorkspaceTerminalTabActions({
|
} = useWorkspaceTerminalTabActions({
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
openWorkspaceTabFocused,
|
openWorkspaceTabFocused,
|
||||||
openWorkspaceTabInFocusedPane,
|
|
||||||
labels: {
|
labels: {
|
||||||
workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"),
|
workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"),
|
||||||
terminalQueued: t("workspace.header.toasts.terminalQueued"),
|
terminalQueued: t("workspace.header.toasts.terminalQueued"),
|
||||||
@@ -1612,11 +1639,10 @@ function WorkspaceScreenContent({
|
|||||||
workspace: workspaceDescriptor,
|
workspace: workspaceDescriptor,
|
||||||
checkoutState: workspaceHeaderCheckoutState,
|
checkoutState: workspaceHeaderCheckoutState,
|
||||||
});
|
});
|
||||||
usePullRequestAutoAdd({
|
const hasPullRequest = useHasPullRequest({
|
||||||
workspaceKey: persistenceKey,
|
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
enabled: canObservePullRequest(isRouteFocused, isGitCheckout, isMobile),
|
enabled: canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile),
|
||||||
});
|
});
|
||||||
|
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
@@ -1632,21 +1658,21 @@ function WorkspaceScreenContent({
|
|||||||
};
|
};
|
||||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||||
|
|
||||||
const isExplorerOpen = useIsExplorerSurfaceOpen({
|
const isSidePanelShowing = useIsSidePanelOpen({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const explorerToggleAccessibilityState = useMemo(
|
const sidePanelToggleAccessibilityState = useMemo(
|
||||||
() => ({ expanded: isExplorerOpen }),
|
() => ({ expanded: isSidePanelShowing }),
|
||||||
[isExplorerOpen],
|
[isSidePanelShowing],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Back dismisses the compact overlay only. On a wide native layout the
|
// Back dismisses the compact overlay only. On a wide native layout the
|
||||||
// explorer is a tab, `showMobileAgent` has no rendered consumer, and
|
// explorer is a tab, `showMobileAgent` has no rendered consumer, and
|
||||||
// returning true would swallow Back with nothing to show for it.
|
// returning true would swallow Back with nothing to show for it.
|
||||||
if (!isRouteFocused || isWeb || !isMobile || !isExplorerOpen) {
|
if (!isRouteFocused || isWeb || !isMobile || !isSidePanelShowing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,7 +1682,7 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => handler.remove();
|
return () => handler.remove();
|
||||||
}, [isExplorerOpen, isMobile, isRouteFocused, showMobileAgent]);
|
}, [isSidePanelShowing, isMobile, isRouteFocused, showMobileAgent]);
|
||||||
|
|
||||||
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
||||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||||
@@ -1682,23 +1708,27 @@ function WorkspaceScreenContent({
|
|||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
});
|
});
|
||||||
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
const openWorkspaceTabInBackground = useCallback(
|
||||||
(state) => state.openTabInBackground,
|
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
|
||||||
|
openTab({ workspaceKey, target, intent: "background", placement }),
|
||||||
|
[openTab],
|
||||||
);
|
);
|
||||||
const openWorkspaceTabInExplorerPaneBackground = useWorkspaceLayoutStore(
|
const openInSidePanelByDefault = useSettings(
|
||||||
(state) => state.openTabInExplorerPaneBackground,
|
(settings) => settings.openSupportingTabsInSidePanel,
|
||||||
);
|
);
|
||||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||||
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
const replaceWorkspaceTabTarget = useWorkspaceLayoutStore((state) => state.replaceTab);
|
||||||
|
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||||
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
|
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
|
||||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||||
const handleToggleExplorer = useCallback(() => {
|
const closeWorkspacePane = useWorkspaceLayoutStore((state) => state.closePane);
|
||||||
toggleExplorerSurface({
|
const handleToggleSidePanel = useCallback(() => {
|
||||||
|
toggleSidePanel({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
checkout: activeExplorerCheckout,
|
checkout: activeExplorerCheckout,
|
||||||
@@ -1836,22 +1866,13 @@ function WorkspaceScreenContent({
|
|||||||
if (input?.ambient) {
|
if (input?.ambient) {
|
||||||
return openWorkspaceTabFocused(persistenceKey, target);
|
return openWorkspaceTabFocused(persistenceKey, target);
|
||||||
}
|
}
|
||||||
|
const placement = paneLocalPlacement(input?.paneId);
|
||||||
if (input?.focus === false) {
|
if (input?.focus === false) {
|
||||||
return openWorkspaceTabInBackground(persistenceKey, target, {
|
return openWorkspaceTabInBackground(persistenceKey, target, placement);
|
||||||
paneId: input?.paneId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (input?.paneId) {
|
return openWorkspaceTabFocused(persistenceKey, target, placement);
|
||||||
return openWorkspaceTabFocused(persistenceKey, target, { paneId: input.paneId });
|
|
||||||
}
|
|
||||||
return openWorkspaceTabInFocusedPane(persistenceKey, target);
|
|
||||||
},
|
},
|
||||||
[
|
[openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey],
|
||||||
openWorkspaceTabFocused,
|
|
||||||
openWorkspaceTabInBackground,
|
|
||||||
openWorkspaceTabInFocusedPane,
|
|
||||||
persistenceKey,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1932,12 +1953,16 @@ function WorkspaceScreenContent({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabId = openWorkspaceTabInFocusedPane(persistenceKey, { kind: "agent", agentId });
|
const tabId = openWorkspaceTabFocused(
|
||||||
|
persistenceKey,
|
||||||
|
{ kind: "agent", agentId },
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigateToTabId, openWorkspaceTabInFocusedPane, persistenceKey],
|
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
||||||
@@ -1980,10 +2005,12 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabId = openWorkspaceTabBesideInBackground({
|
const tabId = openSupportingTab({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
target,
|
target,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
background: true,
|
||||||
});
|
});
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return;
|
return;
|
||||||
@@ -1995,8 +2022,7 @@ function WorkspaceScreenContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
openWorkspaceTabInBackground,
|
openInSidePanelByDefault,
|
||||||
openWorkspaceTabInExplorerPaneBackground,
|
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
showWorkspaceSetup,
|
showWorkspaceSetup,
|
||||||
workspaceSetupSnapshot,
|
workspaceSetupSnapshot,
|
||||||
@@ -2030,7 +2056,9 @@ function WorkspaceScreenContent({
|
|||||||
hasLoadedTerminals: terminalsQuery.isSuccess,
|
hasLoadedTerminals: terminalsQuery.isSuccess,
|
||||||
activeAgentCount: workspaceAgentVisibility.activeAgentIds.size,
|
activeAgentCount: workspaceAgentVisibility.activeAgentIds.size,
|
||||||
terminalCount: terminals.length,
|
terminalCount: terminals.length,
|
||||||
tabCount: tabs.length,
|
// Workspace-wide, like every other count here. A pane-scoped count made an
|
||||||
|
// empty pane look like an empty workspace and reseeded a draft on top of it.
|
||||||
|
tabCount: uiTabs.length,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
emptyWorkspaceSeedRef.current = null;
|
emptyWorkspaceSeedRef.current = null;
|
||||||
@@ -2052,13 +2080,13 @@ function WorkspaceScreenContent({
|
|||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
terminals.length,
|
terminals.length,
|
||||||
terminalsQuery.isSuccess,
|
terminalsQuery.isSuccess,
|
||||||
tabs.length,
|
uiTabs.length,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
workspaceAgentVisibility.activeAgentIds.size,
|
workspaceAgentVisibility.activeAgentIds.size,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleOpenFileFromChat = useCallback(
|
const handleOpenFileFromChat = useCallback(
|
||||||
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
|
(location: WorkspaceFileLocation, parentTabId?: string | null) => {
|
||||||
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
||||||
if (!normalizedLocation) {
|
if (!normalizedLocation) {
|
||||||
return;
|
return;
|
||||||
@@ -2070,9 +2098,9 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = createWorkspaceFileTabTarget(normalizedLocation);
|
const target = createWorkspaceFileTabTarget(normalizedLocation);
|
||||||
const tabId = options?.parentTabId
|
const tabId = parentTabId
|
||||||
? openWorkspaceChildTabInFocusedPane(persistenceKey, target, options.parentTabId)
|
? revealWorkspaceChildTab(persistenceKey, target, parentTabId, FOCUSED_PANE_PLACEMENT)
|
||||||
: openWorkspaceTabInFocusedPane(persistenceKey, target);
|
: openWorkspaceTabFocused(persistenceKey, target, FOCUSED_PANE_PLACEMENT);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
requestFileNavigation(tabId);
|
requestFileNavigation(tabId);
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
@@ -2081,15 +2109,15 @@ function WorkspaceScreenContent({
|
|||||||
[
|
[
|
||||||
isMobile,
|
isMobile,
|
||||||
navigateToTabId,
|
navigateToTabId,
|
||||||
openWorkspaceChildTabInFocusedPane,
|
openWorkspaceTabFocused,
|
||||||
openWorkspaceTabInFocusedPane,
|
revealWorkspaceChildTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
requestFileNavigation,
|
requestFileNavigation,
|
||||||
showMobileAgent,
|
showMobileAgent,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAssistantFileInExplorerPane = useCallback(
|
const handleOpenAssistantFileInSidePanel = useCallback(
|
||||||
(input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => {
|
(input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => {
|
||||||
const location = normalizeWorkspaceFileLocation(input.location);
|
const location = normalizeWorkspaceFileLocation(input.location);
|
||||||
if (!location) {
|
if (!location) {
|
||||||
@@ -2102,10 +2130,11 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabId = openWorkspaceTabBeside({
|
const tabId = openSupportingTab({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
target: createWorkspaceFileTabTarget(location),
|
target: createWorkspaceFileTabTarget(location),
|
||||||
|
openInSidePanelByDefault,
|
||||||
parentTabId: input.parentTabId,
|
parentTabId: input.parentTabId,
|
||||||
});
|
});
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
@@ -2113,7 +2142,14 @@ function WorkspaceScreenContent({
|
|||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isMobile, navigateToTabId, persistenceKey, requestFileNavigation, showMobileAgent],
|
[
|
||||||
|
isMobile,
|
||||||
|
navigateToTabId,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
persistenceKey,
|
||||||
|
requestFileNavigation,
|
||||||
|
showMobileAgent,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
|
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
|
||||||
@@ -2131,13 +2167,13 @@ function WorkspaceScreenContent({
|
|||||||
focusWorkspacePane(persistenceKey, paneId);
|
focusWorkspacePane(persistenceKey, paneId);
|
||||||
}
|
}
|
||||||
if (request.disposition === "side") {
|
if (request.disposition === "side") {
|
||||||
handleOpenAssistantFileInExplorerPane({
|
handleOpenAssistantFileInSidePanel({
|
||||||
location: request.location,
|
location: request.location,
|
||||||
parentTabId,
|
parentTabId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleOpenFileFromChat(request.location, { parentTabId });
|
handleOpenFileFromChat(request.location, parentTabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
||||||
@@ -2197,9 +2233,9 @@ function WorkspaceScreenContent({
|
|||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
const explorerToggleLabel = isExplorerOpen
|
const sidePanelToggleLabel = isSidePanelShowing
|
||||||
? t("workspace.tabs.explorer.close")
|
? t("workspace.tabs.sidePanel.close")
|
||||||
: t("workspace.tabs.explorer.open");
|
: t("workspace.tabs.sidePanel.open");
|
||||||
|
|
||||||
const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]);
|
const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]);
|
||||||
const tabFallbackLabels = useMemo(
|
const tabFallbackLabels = useMemo(
|
||||||
@@ -2236,6 +2272,16 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const handleCreateTerminal = useStableEvent(createTerminal);
|
const handleCreateTerminal = useStableEvent(createTerminal);
|
||||||
|
|
||||||
|
const handleOpenPaneView = useCallback(
|
||||||
|
(input: { paneId: string; target: WorkspaceTabTarget }) => {
|
||||||
|
if (!persistenceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createWorkspaceTab(persistenceKey, input.target, paneLocalPlacement(input.paneId));
|
||||||
|
},
|
||||||
|
[createWorkspaceTab, persistenceKey],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateTerminalWithProfile = useCallback(
|
const handleCreateTerminalWithProfile = useCallback(
|
||||||
(profile: TerminalProfile) => {
|
(profile: TerminalProfile) => {
|
||||||
createTerminal({ profile });
|
createTerminal({ profile });
|
||||||
@@ -2249,19 +2295,13 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { browserId } = createWorkspaceBrowser();
|
const { browserId } = createWorkspaceBrowser();
|
||||||
if (input?.paneId) {
|
openWorkspaceTabFocused(
|
||||||
openWorkspaceTabFocused(
|
persistenceKey,
|
||||||
persistenceKey,
|
{ kind: "browser", browserId },
|
||||||
{ kind: "browser", browserId },
|
paneLocalPlacement(input?.paneId),
|
||||||
{
|
);
|
||||||
paneId: input.paneId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "browser", browserId });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[openWorkspaceTabFocused, openWorkspaceTabInFocusedPane, persistenceKey],
|
[openWorkspaceTabFocused, persistenceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenUrlInBrowserTab = useCallback(
|
const handleOpenUrlInBrowserTab = useCallback(
|
||||||
@@ -2270,7 +2310,11 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
|
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
|
||||||
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
|
openWorkspaceTabFocused(
|
||||||
|
persistenceKey,
|
||||||
|
{ kind: "browser", browserId },
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[openWorkspaceTabFocused, persistenceKey],
|
[openWorkspaceTabFocused, persistenceKey],
|
||||||
);
|
);
|
||||||
@@ -2288,20 +2332,16 @@ function WorkspaceScreenContent({
|
|||||||
[navigateToTabId],
|
[navigateToTabId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateDraftSplit = useCallback(
|
// The new pane opens empty and the user picks what goes in it from the launcher.
|
||||||
|
// Seeding a draft here guessed for them, and guessed "new agent" every time.
|
||||||
|
const handleCreateEmptySplit = useCallback(
|
||||||
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
|
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
splitWorkspacePaneEmpty(persistenceKey, input);
|
||||||
const paneId = splitWorkspacePaneEmpty(persistenceKey, input);
|
|
||||||
if (!paneId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCreateDraftTab({ paneId });
|
|
||||||
},
|
},
|
||||||
[handleCreateDraftTab, persistenceKey, splitWorkspacePaneEmpty],
|
[persistenceKey, splitWorkspacePaneEmpty],
|
||||||
);
|
);
|
||||||
|
|
||||||
const killTerminalAsync = killTerminalMutation.mutateAsync;
|
const killTerminalAsync = killTerminalMutation.mutateAsync;
|
||||||
@@ -2621,14 +2661,23 @@ function WorkspaceScreenContent({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openWorkspaceTabBeside({ isCompact: isMobile, workspaceKey: persistenceKey, target });
|
openSupportingTab({
|
||||||
}, [isMobile, normalizedWorkspaceId, persistenceKey]);
|
isCompact: isMobile,
|
||||||
|
workspaceKey: persistenceKey,
|
||||||
|
target,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
});
|
||||||
|
}, [isMobile, normalizedWorkspaceId, openInSidePanelByDefault, persistenceKey]);
|
||||||
|
|
||||||
const handleBulkCloseTabs = useCallback(
|
const handleBulkCloseTabs = useCallback(
|
||||||
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
async (input: {
|
||||||
|
tabsToClose: WorkspaceTabDescriptor[];
|
||||||
|
title: string;
|
||||||
|
logLabel: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
const { tabsToClose, title, logLabel } = input;
|
const { tabsToClose, title, logLabel } = input;
|
||||||
if (tabsToClose.length === 0) {
|
if (tabsToClose.length === 0) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
|
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
|
||||||
@@ -2655,7 +2704,7 @@ function WorkspaceScreenContent({
|
|||||||
destructive: true,
|
destructive: true,
|
||||||
});
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
@@ -2690,6 +2739,7 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
archiveAgent,
|
archiveAgent,
|
||||||
@@ -2767,6 +2817,42 @@ function WorkspaceScreenContent({
|
|||||||
[handleCloseOtherTabsInPane, tabs],
|
[handleCloseOtherTabsInPane, tabs],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClosePane = useCallback(
|
||||||
|
async (paneId: string) => {
|
||||||
|
if (!persistenceKey || !workspaceLayout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pane = findPaneById(workspaceLayout.root, paneId);
|
||||||
|
// Ask before tearing anything down. The layout refuses to dismiss the final
|
||||||
|
// visible pane, and discovering that after closing its tabs would cost the
|
||||||
|
// user the tabs and leave the pane standing.
|
||||||
|
if (!pane || !canDismissPaneInLayout(workspaceLayout, paneId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabsToClose = pane.tabIds.flatMap((tabId) => {
|
||||||
|
const tab = allTabDescriptorsById.get(tabId);
|
||||||
|
return tab ? [tab] : [];
|
||||||
|
});
|
||||||
|
const closed = await handleBulkCloseTabs({
|
||||||
|
tabsToClose,
|
||||||
|
title: t("workspace.tabs.confirmations.closePaneTitle"),
|
||||||
|
logLabel: "from pane close",
|
||||||
|
});
|
||||||
|
if (!closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeWorkspacePane(persistenceKey, paneId);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
allTabDescriptorsById,
|
||||||
|
closeWorkspacePane,
|
||||||
|
handleBulkCloseTabs,
|
||||||
|
persistenceKey,
|
||||||
|
t,
|
||||||
|
workspaceLayout,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleWorkspaceTabAction = useCallback(
|
const handleWorkspaceTabAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
@@ -2821,7 +2907,7 @@ function WorkspaceScreenContent({
|
|||||||
const handleWorkspaceSidebarAction = useCallback(
|
const handleWorkspaceSidebarAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (action.id === "sidebar.toggle.right") {
|
if (action.id === "sidebar.toggle.right") {
|
||||||
handleToggleExplorer();
|
handleToggleSidePanel();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (action.id !== "sidebar.toggle.both") {
|
if (action.id !== "sidebar.toggle.both") {
|
||||||
@@ -2832,17 +2918,17 @@ function WorkspaceScreenContent({
|
|||||||
const panel = usePanelStore.getState();
|
const panel = usePanelStore.getState();
|
||||||
toggleDesktopSidebarsWithCheckoutIntent({
|
toggleDesktopSidebarsWithCheckoutIntent({
|
||||||
isAgentListOpen: selectIsAgentListOpen(panel, { isCompact: isMobile }),
|
isAgentListOpen: selectIsAgentListOpen(panel, { isCompact: isMobile }),
|
||||||
isExplorerOpen: isExplorerSurfaceOpen({
|
isExplorerOpen: isSidePanelOpen({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
}),
|
}),
|
||||||
openAgentList: () => panel.openAgentListForLayout({ isCompact: isMobile }),
|
openAgentList: () => panel.openAgentListForLayout({ isCompact: isMobile }),
|
||||||
closeAgentList: () => panel.closeAgentListForLayout({ isCompact: isMobile }),
|
closeAgentList: () => panel.closeAgentListForLayout({ isCompact: isMobile }),
|
||||||
toggleExplorer: handleToggleExplorer,
|
toggleExplorer: handleToggleSidePanel,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[handleToggleExplorer, isMobile, persistenceKey],
|
[handleToggleSidePanel, isMobile, persistenceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkspacePaneAction = useCallback(
|
const handleWorkspacePaneAction = useCallback(
|
||||||
@@ -2862,7 +2948,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.right") {
|
if (action.id === "workspace.pane.split.right") {
|
||||||
handleCreateDraftSplit({
|
handleCreateEmptySplit({
|
||||||
targetPaneId: focusedPane.id,
|
targetPaneId: focusedPane.id,
|
||||||
position: "right",
|
position: "right",
|
||||||
});
|
});
|
||||||
@@ -2870,7 +2956,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.down") {
|
if (action.id === "workspace.pane.split.down") {
|
||||||
handleCreateDraftSplit({
|
handleCreateEmptySplit({
|
||||||
targetPaneId: focusedPane.id,
|
targetPaneId: focusedPane.id,
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
});
|
});
|
||||||
@@ -2905,31 +2991,21 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.close") {
|
if (action.id === "workspace.pane.close") {
|
||||||
const tabsToClose = focusedPane.tabIds.flatMap((tabId) => {
|
void handleClosePane(focusedPane.id);
|
||||||
const tab = allTabDescriptorsById.get(tabId);
|
|
||||||
return tab ? [tab] : [];
|
|
||||||
});
|
|
||||||
void handleBulkCloseTabs({
|
|
||||||
tabsToClose,
|
|
||||||
title: t("workspace.tabs.confirmations.closePaneTitle"),
|
|
||||||
logLabel: "from pane close",
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
allTabDescriptorsById,
|
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
handleBulkCloseTabs,
|
handleClosePane,
|
||||||
handleCreateDraftSplit,
|
handleCreateEmptySplit,
|
||||||
moveWorkspaceTabToPane,
|
moveWorkspaceTabToPane,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
focusedPaneTabState.activeTabId,
|
focusedPaneTabState.activeTabId,
|
||||||
focusedPaneTabState.pane,
|
focusedPaneTabState.pane,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
t,
|
|
||||||
workspaceLayout,
|
workspaceLayout,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -3033,9 +3109,12 @@ function WorkspaceScreenContent({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId, {
|
const tabId = revealWorkspaceChildTab(
|
||||||
paneId: input.focusPaneBeforeOpen ? input.paneId : null,
|
persistenceKey,
|
||||||
});
|
target,
|
||||||
|
input.tab.tabId,
|
||||||
|
paneLocalPlacement(input.focusPaneBeforeOpen ? input.paneId : null),
|
||||||
|
);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
@@ -3047,7 +3126,21 @@ function WorkspaceScreenContent({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
if (target.kind === "file" && input.tab.target.kind === "files") {
|
||||||
|
replaceWorkspaceTabTarget(
|
||||||
|
persistenceKey,
|
||||||
|
input.tab.tabId,
|
||||||
|
target,
|
||||||
|
fileStateForFilesView,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
replaceWorkspaceTabTarget(persistenceKey, input.tab.tabId, target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSetCurrentTabState: (state) => {
|
||||||
|
if (persistenceKey) {
|
||||||
|
setWorkspaceTabState(persistenceKey, input.tab.tabId, state);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
|
||||||
handleOpenWorkspaceFileFromPane({
|
handleOpenWorkspaceFileFromPane({
|
||||||
@@ -3067,9 +3160,10 @@ function WorkspaceScreenContent({
|
|||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
openImportSheet,
|
openImportSheet,
|
||||||
openWorkspaceChildTabFocused,
|
revealWorkspaceChildTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
retargetWorkspaceTab,
|
replaceWorkspaceTabTarget,
|
||||||
|
setWorkspaceTabState,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const focusedPaneId = useMemo(
|
const focusedPaneId = useMemo(
|
||||||
@@ -3204,14 +3298,37 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderSplitPaneEmptyState = useCallback(
|
const renderSplitPaneEmptyState = useCallback(
|
||||||
function renderSplitPaneEmptyState() {
|
function renderSplitPaneEmptyState(input: {
|
||||||
|
paneId: string;
|
||||||
|
paneIsolated: boolean;
|
||||||
|
onClosePane: () => void;
|
||||||
|
}) {
|
||||||
|
// Same predicate the close action reads, so the button is offered exactly when
|
||||||
|
// pressing it would do something.
|
||||||
|
const canClosePane =
|
||||||
|
!input.paneIsolated &&
|
||||||
|
Boolean(workspaceLayout) &&
|
||||||
|
canDismissPaneInLayout(workspaceLayout as WorkspaceLayout, input.paneId);
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<WorkspacePaneEmptyState
|
||||||
<Text style={styles.emptyStateText}>{t("workspace.tabs.emptyPane")}</Text>
|
paneId={input.paneId}
|
||||||
</View>
|
showChanges={isGitCheckout}
|
||||||
|
showPullRequest={hasPullRequest}
|
||||||
|
onOpenView={handleOpenPaneView}
|
||||||
|
onCreateAgent={handleCreateDraftTab}
|
||||||
|
onCreateTerminal={handleCreateTerminal}
|
||||||
|
onClosePane={canClosePane ? input.onClosePane : null}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[t],
|
[
|
||||||
|
handleCreateDraftTab,
|
||||||
|
handleCreateTerminal,
|
||||||
|
handleOpenPaneView,
|
||||||
|
hasPullRequest,
|
||||||
|
isGitCheckout,
|
||||||
|
workspaceLayout,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerStyle = [styles.container, styles.containerWorkspaceBackground];
|
const containerStyle = [styles.container, styles.containerWorkspaceBackground];
|
||||||
@@ -3259,15 +3376,15 @@ function WorkspaceScreenContent({
|
|||||||
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
|
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
testID="workspace-explorer-toggle"
|
testID="workspace-explorer-toggle"
|
||||||
onPress={handleToggleExplorer}
|
onPress={handleToggleSidePanel}
|
||||||
tooltipLabel={t("workspace.tabs.explorer.toggle")}
|
tooltipLabel={t("workspace.tabs.sidePanel.toggle")}
|
||||||
tooltipKeys={EXPLORER_TOGGLE_KEYS}
|
tooltipKeys={EXPLORER_TOGGLE_KEYS}
|
||||||
tooltipSide="left"
|
tooltipSide="left"
|
||||||
style={styles.compactHeaderActionButton}
|
style={styles.compactHeaderActionButton}
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={explorerToggleLabel}
|
accessibilityLabel={sidePanelToggleLabel}
|
||||||
accessibilityState={explorerToggleAccessibilityState}
|
accessibilityState={sidePanelToggleAccessibilityState}
|
||||||
>
|
>
|
||||||
{({ hovered }) => {
|
{({ hovered }) => {
|
||||||
const colorMapping = hovered ? foregroundColorMapping : extraMutedColorMapping;
|
const colorMapping = hovered ? foregroundColorMapping : extraMutedColorMapping;
|
||||||
@@ -3279,15 +3396,15 @@ function WorkspaceScreenContent({
|
|||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
testID="workspace-explorer-toggle"
|
testID="workspace-explorer-toggle"
|
||||||
onPress={handleToggleExplorer}
|
onPress={handleToggleSidePanel}
|
||||||
tooltipLabel={t("workspace.tabs.explorer.toggle")}
|
tooltipLabel={t("workspace.tabs.sidePanel.toggle")}
|
||||||
tooltipKeys={EXPLORER_TOGGLE_KEYS}
|
tooltipKeys={EXPLORER_TOGGLE_KEYS}
|
||||||
tooltipSide="left"
|
tooltipSide="left"
|
||||||
style={styles.headerActionButton}
|
style={styles.headerActionButton}
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={explorerToggleLabel}
|
accessibilityLabel={sidePanelToggleLabel}
|
||||||
accessibilityState={explorerToggleAccessibilityState}
|
accessibilityState={sidePanelToggleAccessibilityState}
|
||||||
>
|
>
|
||||||
{({ hovered }) => {
|
{({ hovered }) => {
|
||||||
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
|
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
|
||||||
@@ -3309,9 +3426,9 @@ function WorkspaceScreenContent({
|
|||||||
handleScriptTerminalStarted,
|
handleScriptTerminalStarted,
|
||||||
handleViewScriptTerminal,
|
handleViewScriptTerminal,
|
||||||
handleOpenUrlInBrowserTab,
|
handleOpenUrlInBrowserTab,
|
||||||
handleToggleExplorer,
|
handleToggleSidePanel,
|
||||||
explorerToggleLabel,
|
sidePanelToggleLabel,
|
||||||
explorerToggleAccessibilityState,
|
sidePanelToggleAccessibilityState,
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -3370,8 +3487,9 @@ function WorkspaceScreenContent({
|
|||||||
buildPaneContentModel={buildDesktopPaneContentModel}
|
buildPaneContentModel={buildDesktopPaneContentModel}
|
||||||
onFocusPane={handleFocusPane}
|
onFocusPane={handleFocusPane}
|
||||||
onSplitPane={handleSplitPane}
|
onSplitPane={handleSplitPane}
|
||||||
onSplitPaneEmpty={handleCreateDraftSplit}
|
onSplitPaneEmpty={handleCreateEmptySplit}
|
||||||
onMoveTabToPane={handleMoveTabToPane}
|
onMoveTabToPane={handleMoveTabToPane}
|
||||||
|
onClosePane={handleClosePane}
|
||||||
onResizeSplit={handleResizePaneSplit}
|
onResizeSplit={handleResizePaneSplit}
|
||||||
onReorderTabsInPane={handleReorderTabsInPane}
|
onReorderTabsInPane={handleReorderTabsInPane}
|
||||||
renderPaneEmptyState={renderSplitPaneEmptyState}
|
renderPaneEmptyState={renderSplitPaneEmptyState}
|
||||||
@@ -3407,8 +3525,9 @@ function WorkspaceScreenContent({
|
|||||||
buildDesktopPaneContentModel,
|
buildDesktopPaneContentModel,
|
||||||
handleFocusPane,
|
handleFocusPane,
|
||||||
handleSplitPane,
|
handleSplitPane,
|
||||||
handleCreateDraftSplit,
|
handleCreateEmptySplit,
|
||||||
handleMoveTabToPane,
|
handleMoveTabToPane,
|
||||||
|
handleClosePane,
|
||||||
handleResizePaneSplit,
|
handleResizePaneSplit,
|
||||||
handleReorderTabsInPane,
|
handleReorderTabsInPane,
|
||||||
renderSplitPaneEmptyState,
|
renderSplitPaneEmptyState,
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export interface WorkspaceTabDescriptor {
|
|||||||
tabId: string;
|
tabId: string;
|
||||||
kind: WorkspaceTabTarget["kind"];
|
kind: WorkspaceTabTarget["kind"];
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
|
state?: import("@getpaseo/protocol/agent-types").JsonValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
|||||||
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
||||||
getSessionAgents: (serverId) =>
|
getSessionAgents: (serverId) =>
|
||||||
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
|
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
|
||||||
openTabFocused: (workspaceKey, target) =>
|
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
|
||||||
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, target),
|
|
||||||
pinAgent: (workspaceKey, agentId) =>
|
pinAgent: (workspaceKey, agentId) =>
|
||||||
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
|
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
|
||||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
|||||||
const deps: NavigateToWorkspaceDeps = {
|
const deps: NavigateToWorkspaceDeps = {
|
||||||
getSessionWorkspaces: () => null,
|
getSessionWorkspaces: () => null,
|
||||||
getSessionAgents: () => [] as Agent[],
|
getSessionAgents: () => [] as Agent[],
|
||||||
openTabFocused: (workspaceKey, target) => {
|
openTab: ({ workspaceKey, target }) => {
|
||||||
openedTabs.push({ workspaceKey, target });
|
openedTabs.push({ workspaceKey, target });
|
||||||
return target.kind === "agent" ? target.agentId : null;
|
return target.kind === "agent" ? target.agentId : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,9 +100,10 @@ export function navigateToWorkspace(
|
|||||||
: [];
|
: [];
|
||||||
const attentionAgentId = pickAttentionAgent(workspaceAgents);
|
const attentionAgentId = pickAttentionAgent(workspaceAgents);
|
||||||
if (attentionAgentId && resolvedWorkspaceId) {
|
if (attentionAgentId && resolvedWorkspaceId) {
|
||||||
deps.openTabFocused(`${input.serverId}:${resolvedWorkspaceId}`, {
|
deps.openTab({
|
||||||
kind: "agent",
|
workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`,
|
||||||
agentId: attentionAgentId,
|
target: { kind: "agent", agentId: attentionAgentId },
|
||||||
|
intent: "reveal",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export interface PanelState {
|
|||||||
explorerSortOption: SortOption;
|
explorerSortOption: SortOption;
|
||||||
explorerShowHiddenFiles: boolean;
|
explorerShowHiddenFiles: boolean;
|
||||||
treeRailWidth: number;
|
treeRailWidth: number;
|
||||||
|
// File panel's tree rail. The changes panel keeps its own flag in
|
||||||
|
// `useChangesPreferences`; the two rails open and close independently.
|
||||||
|
fileTreeVisible: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
toggleFocusMode: () => void;
|
toggleFocusMode: () => void;
|
||||||
@@ -103,6 +106,7 @@ export interface PanelState {
|
|||||||
setExplorerSortOption: (option: SortOption) => void;
|
setExplorerSortOption: (option: SortOption) => void;
|
||||||
toggleExplorerShowHiddenFiles: () => void;
|
toggleExplorerShowHiddenFiles: () => void;
|
||||||
setTreeRailWidth: (width: number) => void;
|
setTreeRailWidth: (width: number) => void;
|
||||||
|
toggleFileTreeVisible: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_OPEN = isWeb;
|
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||||
@@ -137,6 +141,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
explorerSortOption: "name",
|
explorerSortOption: "name",
|
||||||
explorerShowHiddenFiles: true,
|
explorerShowHiddenFiles: true,
|
||||||
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
|
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
|
||||||
|
fileTreeVisible: true,
|
||||||
|
|
||||||
toggleFocusMode: () =>
|
toggleFocusMode: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -278,10 +283,11 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
toggleExplorerShowHiddenFiles: () =>
|
toggleExplorerShowHiddenFiles: () =>
|
||||||
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
|
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
|
||||||
setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
|
setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
|
||||||
|
toggleFileTreeVisible: () => set((state) => ({ fileTreeVisible: !state.fileTreeVisible })),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "panel-state",
|
name: "panel-state",
|
||||||
version: 15,
|
version: 16,
|
||||||
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
|
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
|
||||||
migrate: (persistedState, version) => migratePanelState(persistedState, version),
|
migrate: (persistedState, version) => migratePanelState(persistedState, version),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
@@ -295,6 +301,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
explorerSortOption: state.explorerSortOption,
|
explorerSortOption: state.explorerSortOption,
|
||||||
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
|
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
|
||||||
treeRailWidth: state.treeRailWidth,
|
treeRailWidth: state.treeRailWidth,
|
||||||
|
fileTreeVisible: state.fileTreeVisible,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ describe("panel-store migration", () => {
|
|||||||
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
|
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("narrows an existing install's tree rail to the new default width", () => {
|
||||||
|
expect(DEFAULT_TREE_RAIL_WIDTH).toBeLessThan(320);
|
||||||
|
expect(migratePanelState({ treeRailWidth: 320 }, 15).treeRailWidth).toBe(
|
||||||
|
DEFAULT_TREE_RAIL_WIDTH,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the file panel tree to open and keeps it closed once closed", () => {
|
||||||
|
expect(migratePanelState({}, 15).fileTreeVisible).toBe(true);
|
||||||
|
expect(migratePanelState({ fileTreeVisible: false }, 16).fileTreeVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("drops persisted compact panel state so cold starts return to content", () => {
|
it("drops persisted compact panel state so cold starts return to content", () => {
|
||||||
const state = migratePanelState(
|
const state = migratePanelState(
|
||||||
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
|
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export const DEFAULT_SIDEBAR_WIDTH = 320;
|
|||||||
export const MIN_SIDEBAR_WIDTH = 200;
|
export const MIN_SIDEBAR_WIDTH = 200;
|
||||||
export const MAX_SIDEBAR_WIDTH = 600;
|
export const MAX_SIDEBAR_WIDTH = 600;
|
||||||
|
|
||||||
export const DEFAULT_TREE_RAIL_WIDTH = 320;
|
export const DEFAULT_TREE_RAIL_WIDTH = 260;
|
||||||
export const MIN_TREE_RAIL_WIDTH = 200;
|
export const MIN_TREE_RAIL_WIDTH = 180;
|
||||||
export const MAX_TREE_RAIL_WIDTH = 600;
|
export const MAX_TREE_RAIL_WIDTH = 600;
|
||||||
|
|
||||||
export interface PanelLayoutInput {
|
export interface PanelLayoutInput {
|
||||||
@@ -151,6 +151,7 @@ export const PanelPersistedStateSchema = z.strictObject({
|
|||||||
explorerShowHiddenFiles: z.boolean().optional(),
|
explorerShowHiddenFiles: z.boolean().optional(),
|
||||||
explorerFilesSplitRatio: z.number().optional(),
|
explorerFilesSplitRatio: z.number().optional(),
|
||||||
treeRailWidth: z.number().optional(),
|
treeRailWidth: z.number().optional(),
|
||||||
|
fileTreeVisible: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
|
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
|
||||||
@@ -193,8 +194,10 @@ function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v16 narrowed the rail. Existing installs almost all carry the old 320 default,
|
||||||
|
// so the reset is what makes the narrower rail visible to anyone but a new user.
|
||||||
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
|
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
|
||||||
if (version < 13 || typeof state.treeRailWidth !== "number") {
|
if (version < 16 || typeof state.treeRailWidth !== "number") {
|
||||||
delete state.explorerFilesSplitRatio;
|
delete state.explorerFilesSplitRatio;
|
||||||
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
|
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
|
||||||
return;
|
return;
|
||||||
@@ -248,6 +251,9 @@ export function migratePanelState(persistedState: unknown, version: number): Mig
|
|||||||
state.explorerShowHiddenFiles = true;
|
state.explorerShowHiddenFiles = true;
|
||||||
}
|
}
|
||||||
migrateTreeRailWidth(state, version);
|
migrateTreeRailWidth(state, version);
|
||||||
|
if (typeof state.fileTreeVisible !== "boolean") {
|
||||||
|
state.fileTreeVisible = true;
|
||||||
|
}
|
||||||
if (version < 12) {
|
if (version < 12) {
|
||||||
// Compact panel position is transient UI state. Cold starts always begin
|
// Compact panel position is transient UI state. Cold starts always begin
|
||||||
// at content, regardless of what an older version persisted.
|
// at content, regardless of what an older version persisted.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||||
|
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||||
import {
|
import {
|
||||||
@@ -99,14 +101,40 @@ interface InsertSplitInternalResult {
|
|||||||
newPaneId: string;
|
newPaneId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an open wants its tab, and how hard it wants it there.
|
||||||
|
*
|
||||||
|
* The distinction only shows up when the tab already exists somewhere. A user who
|
||||||
|
* picks Changes from a specific pane's `+` menu is placing it there and expects it
|
||||||
|
* to arrive. A file link in an agent's output is a supporting open with an opinion
|
||||||
|
* about new tabs only — it must never yank a tab out from under the pane the user
|
||||||
|
* deliberately moved it to.
|
||||||
|
*/
|
||||||
|
export type WorkspaceTabPlacement =
|
||||||
|
/** Pane-local affordance: new tabs open here, existing tabs move here. */
|
||||||
|
| { mode: "pane"; paneId: string }
|
||||||
|
/** Implicit open: new tabs open here, existing tabs stay where the user left them. */
|
||||||
|
| { mode: "prefer"; paneId: string }
|
||||||
|
/** The user is working in the focused pane, whichever one that is. */
|
||||||
|
| { mode: "focused" }
|
||||||
|
/** Nobody placed this tab — reconciliation and other opens with no user behind them. */
|
||||||
|
| { mode: "ambient" };
|
||||||
|
|
||||||
|
export const FOCUSED_PANE_PLACEMENT: WorkspaceTabPlacement = { mode: "focused" };
|
||||||
|
export const AMBIENT_PLACEMENT: WorkspaceTabPlacement = { mode: "ambient" };
|
||||||
|
|
||||||
interface OpenTabInLayoutInput {
|
interface OpenTabInLayoutInput {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
now: number;
|
now: number;
|
||||||
/** Explicit placement from a pane-local affordance. Wins over every other rule. */
|
placement: WorkspaceTabPlacement;
|
||||||
paneId?: string | null;
|
/** Required so a new caller cannot silently opt out of the side-panel placement rule. */
|
||||||
/** Required so a new caller cannot silently opt out of the explorer placement rule. */
|
sidePanelPaneId: string | null;
|
||||||
explorerPaneId: string | null;
|
}
|
||||||
|
|
||||||
|
interface CreateTabInLayoutInput extends OpenTabInLayoutInput {
|
||||||
|
createTabId: () => string;
|
||||||
|
state?: JsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenTabInLayoutResult {
|
interface OpenTabInLayoutResult {
|
||||||
@@ -120,6 +148,11 @@ interface RetargetTabInLayoutInput {
|
|||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReplaceTabTargetInLayoutInput extends RetargetTabInLayoutInput {
|
||||||
|
createTabId: () => string;
|
||||||
|
state?: JsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
interface RetargetTabInLayoutResult {
|
interface RetargetTabInLayoutResult {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -147,6 +180,11 @@ interface CloseTabInLayoutInput {
|
|||||||
preserveEmptyPaneId?: string | null;
|
preserveEmptyPaneId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClosePaneInLayoutInput {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
paneId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SplitPaneInLayoutInput {
|
interface SplitPaneInLayoutInput {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -202,7 +240,7 @@ export interface WorkspaceTabReconcileState {
|
|||||||
pinnedAgentIds?: ReadonlySet<string> | null;
|
pinnedAgentIds?: ReadonlySet<string> | null;
|
||||||
pendingAgentIds?: ReadonlySet<string> | null;
|
pendingAgentIds?: ReadonlySet<string> | null;
|
||||||
hiddenAgentIds?: ReadonlySet<string> | null;
|
hiddenAgentIds?: ReadonlySet<string> | null;
|
||||||
explorerPaneId: string | null;
|
sidePanelPaneId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceTabSnapshot {
|
export interface WorkspaceTabSnapshot {
|
||||||
@@ -218,7 +256,8 @@ export interface WorkspaceTabSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PANE_ID = "main";
|
const DEFAULT_PANE_ID = "main";
|
||||||
export const DEFAULT_EXPLORER_PANE_ID = "explorer";
|
/** The pane id is persisted, so it keeps its pre-rename spelling. */
|
||||||
|
export const SIDE_PANEL_PANE_ID = "explorer";
|
||||||
const DEFAULT_LAYOUT_GROUP_ID = "workspace-root";
|
const DEFAULT_LAYOUT_GROUP_ID = "workspace-root";
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
@@ -300,18 +339,15 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const tabId =
|
const tabId = trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target);
|
||||||
target.kind === "working_diff"
|
|
||||||
? buildDeterministicWorkspaceTabId(target)
|
|
||||||
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
|
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
target,
|
target,
|
||||||
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(),
|
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(),
|
||||||
|
...(tab.state !== undefined ? { state: tab.state } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,6 +833,7 @@ function replaceTabInTree(
|
|||||||
tabId: string;
|
tabId: string;
|
||||||
nextTabId: string;
|
nextTabId: string;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
|
state?: JsonValue;
|
||||||
},
|
},
|
||||||
): SplitNodeInternal {
|
): SplitNodeInternal {
|
||||||
const panePath = findPanePathContainingTab(root, input.tabId);
|
const panePath = findPanePathContainingTab(root, input.tabId);
|
||||||
@@ -807,15 +844,15 @@ function replaceTabInTree(
|
|||||||
kind: "pane",
|
kind: "pane",
|
||||||
pane: normalizePaneAfterTabChange({
|
pane: normalizePaneAfterTabChange({
|
||||||
...node.pane,
|
...node.pane,
|
||||||
tabs: node.pane.tabs.map((tab) =>
|
tabs: node.pane.tabs.map((tab) => {
|
||||||
tab.tabId === input.tabId
|
if (tab.tabId !== input.tabId) return tab;
|
||||||
? {
|
return {
|
||||||
...tab,
|
tabId: input.nextTabId,
|
||||||
tabId: input.nextTabId,
|
target: input.target,
|
||||||
target: input.target,
|
createdAt: tab.createdAt,
|
||||||
}
|
...(input.state !== undefined ? { state: input.state } : {}),
|
||||||
: tab,
|
};
|
||||||
),
|
}),
|
||||||
focusedTabId:
|
focusedTabId:
|
||||||
node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
|
node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
|
||||||
}),
|
}),
|
||||||
@@ -1076,14 +1113,14 @@ export function createDefaultLayout(): WorkspaceLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The desktop companion pane exists before it is first shown. */
|
/** The desktop companion pane exists before it is first shown. */
|
||||||
export function createWorkspaceLayoutWithExplorer(): WorkspaceLayout {
|
export function createWorkspaceLayoutWithSidePanel(): WorkspaceLayout {
|
||||||
return {
|
return {
|
||||||
root: createGroupNode({
|
root: createGroupNode({
|
||||||
id: DEFAULT_LAYOUT_GROUP_ID,
|
id: DEFAULT_LAYOUT_GROUP_ID,
|
||||||
direction: "horizontal",
|
direction: "horizontal",
|
||||||
children: [
|
children: [
|
||||||
createPaneNode({ id: DEFAULT_PANE_ID }),
|
createPaneNode({ id: DEFAULT_PANE_ID }),
|
||||||
createPaneNode({ id: DEFAULT_EXPLORER_PANE_ID, hidden: true }),
|
createPaneNode({ id: SIDE_PANEL_PANE_ID, hidden: true }),
|
||||||
],
|
],
|
||||||
sizes: [0.5, 0.5],
|
sizes: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
@@ -1125,10 +1162,10 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
|
|||||||
}).root;
|
}).root;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The explorer pane is a background surface for the PR, files, and assistant output.
|
// Tab kinds the user works *in* rather than consults. Reconciliation opens these
|
||||||
// It can hold focus without the user ever having looked at it, so an ambient open of
|
// with nobody behind the click, and the side panel can hold focus from an earlier
|
||||||
// something the user works *in* must not land there just because focus leaked.
|
// reveal, so an ambient open must not drop an agent there just because focus stayed.
|
||||||
const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
|
const SIDE_PANEL_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
|
||||||
"agent",
|
"agent",
|
||||||
"provider_subagent",
|
"provider_subagent",
|
||||||
"terminal",
|
"terminal",
|
||||||
@@ -1139,29 +1176,36 @@ const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new
|
|||||||
function resolvePlacementPane(input: {
|
function resolvePlacementPane(input: {
|
||||||
layout: { root: SplitNode; focusedPaneId: string | null };
|
layout: { root: SplitNode; focusedPaneId: string | null };
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
paneId: string | null | undefined;
|
placement: WorkspaceTabPlacement;
|
||||||
explorerPaneId: string | null;
|
sidePanelPaneId: string | null;
|
||||||
}): SplitPane {
|
}): SplitPane {
|
||||||
const requestedPane = findPaneById(input.layout.root, input.paneId);
|
const requestedPane =
|
||||||
|
input.placement.mode === "pane" || input.placement.mode === "prefer"
|
||||||
|
? findPaneById(input.layout.root, input.placement.paneId)
|
||||||
|
: null;
|
||||||
if (requestedPane) {
|
if (requestedPane) {
|
||||||
return requestedPane;
|
return requestedPane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `collectAllPanes` skips hidden panes, so a focused-but-hidden pane — the side
|
||||||
|
// panel between a reveal and a hide — falls through to a pane the user can see.
|
||||||
|
const focusedCandidate = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
||||||
const focusedPane =
|
const focusedPane =
|
||||||
findPaneById(input.layout.root, input.layout.focusedPaneId) ??
|
(focusedCandidate?.hidden === true ? null : focusedCandidate) ??
|
||||||
collectAllPanes(input.layout.root)[0] ??
|
collectAllPanes(input.layout.root)[0] ??
|
||||||
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
|
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
|
||||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||||
if (
|
if (
|
||||||
focusedPane.id !== input.explorerPaneId ||
|
input.placement.mode !== "ambient" ||
|
||||||
!EXPLORER_EXCLUDED_TAB_KINDS.has(input.target.kind)
|
focusedPane.id !== input.sidePanelPaneId ||
|
||||||
|
!SIDE_PANEL_EXCLUDED_TAB_KINDS.has(input.target.kind)
|
||||||
) {
|
) {
|
||||||
return focusedPane;
|
return focusedPane;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `collectAllPanes` skips hidden panes, so the chain falls back to the explorer
|
// `collectAllPanes` skips hidden panes, so the chain falls back to the side panel
|
||||||
// pane itself only when it is the sole visible pane. The explorer is the only pane
|
// pane itself only when it is the sole visible pane. The side panel is the only
|
||||||
// anything ever hides, so today that means "the explorer is the only pane".
|
// pane anything ever hides, so today that means "the side panel is the only pane".
|
||||||
const panes = collectAllPanes(input.layout.root);
|
const panes = collectAllPanes(input.layout.root);
|
||||||
return (
|
return (
|
||||||
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ??
|
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ??
|
||||||
@@ -1171,21 +1215,22 @@ function resolvePlacementPane(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function insertNewTabIntoPane(
|
function insertNewTabIntoPane(
|
||||||
input: OpenTabInLayoutInput & { focus: boolean },
|
input: CreateTabInLayoutInput & { focus: boolean },
|
||||||
): OpenTabInLayoutResult {
|
): OpenTabInLayoutResult {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
const targetPane = resolvePlacementPane({
|
const targetPane = resolvePlacementPane({
|
||||||
layout,
|
layout,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
paneId: input.paneId,
|
placement: input.placement,
|
||||||
explorerPaneId: input.explorerPaneId,
|
sidePanelPaneId: input.sidePanelPaneId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabId = buildDeterministicWorkspaceTabId(input.target);
|
const tabId = input.createTabId();
|
||||||
const nextTab: WorkspaceTab = {
|
const nextTab: WorkspaceTab = {
|
||||||
tabId,
|
tabId,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
createdAt: input.now,
|
createdAt: input.now,
|
||||||
|
...(input.state !== undefined ? { state: input.state } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const preservedFocusTabId = targetPane.focusedTabId ?? tabId;
|
const preservedFocusTabId = targetPane.focusedTabId ?? tabId;
|
||||||
@@ -1205,10 +1250,15 @@ function insertNewTabIntoPane(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
|
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
|
||||||
const targetTabId = buildDeterministicWorkspaceTabId(target);
|
const registration = getPanelRegistration(target.kind);
|
||||||
|
const targetResourceKey = registration?.resourceKey(target as never);
|
||||||
|
const targetIdentity = targetResourceKey ?? buildDeterministicWorkspaceTabId(target);
|
||||||
return (
|
return (
|
||||||
collectAllTabs(root).find(
|
collectAllTabs(root).find(
|
||||||
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, target),
|
(tab) =>
|
||||||
|
tab.target.kind === target.kind &&
|
||||||
|
(registration?.resourceKey(tab.target as never) ??
|
||||||
|
buildDeterministicWorkspaceTabId(tab.target)) === targetIdentity,
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1277,7 @@ function updateExistingTabTarget(
|
|||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
nextTabId: tab.tabId,
|
nextTabId: tab.tabId,
|
||||||
target,
|
target,
|
||||||
|
state: tab.state,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1238,17 +1289,70 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
|
|||||||
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
|
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
|
||||||
return {
|
return {
|
||||||
tabId: existingTab.tabId,
|
tabId: existingTab.tabId,
|
||||||
layout:
|
layout: revealExistingTab({
|
||||||
focusTabInLayout({
|
layout: nextLayout,
|
||||||
layout: nextLayout,
|
tabId: existingTab.tabId,
|
||||||
tabId: existingTab.tabId,
|
placement: input.placement,
|
||||||
}) ?? nextLayout,
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return insertNewTabIntoPane({
|
||||||
|
...input,
|
||||||
|
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
|
||||||
|
focus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always allocates an independent tab instance. */
|
||||||
|
export function createTabInLayout(input: CreateTabInLayoutInput): OpenTabInLayoutResult {
|
||||||
return insertNewTabIntoPane({ ...input, focus: true });
|
return insertNewTabIntoPane({ ...input, focus: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reveals an equivalent target, or creates it when absent. */
|
||||||
|
export function revealTargetInLayout(input: CreateTabInLayoutInput): OpenTabInLayoutResult {
|
||||||
|
const existingTab = findExistingTabForTarget(asInternalLayout(input.layout).root, input.target);
|
||||||
|
if (existingTab) {
|
||||||
|
return {
|
||||||
|
tabId: existingTab.tabId,
|
||||||
|
layout: revealExistingTab({
|
||||||
|
layout: updateExistingTabTarget(input.layout, existingTab, input.target),
|
||||||
|
tabId: existingTab.tabId,
|
||||||
|
placement: input.placement,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return createTabInLayout({
|
||||||
|
...input,
|
||||||
|
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings an already-open tab to the user. Only an explicit pane-local placement
|
||||||
|
* relocates it; every other open finds it where the user last put it.
|
||||||
|
*/
|
||||||
|
function revealExistingTab(input: {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
tabId: string;
|
||||||
|
placement: WorkspaceTabPlacement;
|
||||||
|
}): WorkspaceLayout {
|
||||||
|
if (input.placement.mode === "pane") {
|
||||||
|
const currentPane = findPaneContainingTab(asInternalNode(input.layout.root), input.tabId);
|
||||||
|
if (currentPane?.id !== input.placement.paneId) {
|
||||||
|
const moved = moveTabToPaneInLayout({
|
||||||
|
layout: input.layout,
|
||||||
|
tabId: input.tabId,
|
||||||
|
toPaneId: input.placement.paneId,
|
||||||
|
});
|
||||||
|
if (moved) {
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return focusTabInLayout({ layout: input.layout, tabId: input.tabId }) ?? input.layout;
|
||||||
|
}
|
||||||
|
|
||||||
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||||
@@ -1259,7 +1363,11 @@ export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabI
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return insertNewTabIntoPane({ ...input, focus: false });
|
return insertNewTabIntoPane({
|
||||||
|
...input,
|
||||||
|
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
|
||||||
|
focus: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
||||||
@@ -1270,7 +1378,7 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
|||||||
}
|
}
|
||||||
const preserveEmptyPaneId =
|
const preserveEmptyPaneId =
|
||||||
input.preserveEmptyPaneId ??
|
input.preserveEmptyPaneId ??
|
||||||
(pane.id === DEFAULT_PANE_ID || pane.id === DEFAULT_EXPLORER_PANE_ID ? pane.id : null);
|
(pane.id === DEFAULT_PANE_ID || pane.id === SIDE_PANEL_PANE_ID ? pane.id : null);
|
||||||
|
|
||||||
const closeSuccessorTabId = getCloseSuccessorTabId({
|
const closeSuccessorTabId = getCloseSuccessorTabId({
|
||||||
pane,
|
pane,
|
||||||
@@ -1313,6 +1421,57 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
|||||||
return nextLayoutWithParentMap;
|
return nextLayoutWithParentMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether dismissing this pane would do anything — removing it, or hiding it if it
|
||||||
|
* is the side panel. A workspace always has somewhere to look, so the last pane the
|
||||||
|
* user can see stays.
|
||||||
|
*
|
||||||
|
* Ask this *before* tearing down the pane's tabs. `closePaneInLayout` and
|
||||||
|
* `setPaneHiddenInLayout` both refuse the same case, but they refuse at the end,
|
||||||
|
* once the tabs are already gone. This is the same rule stated early enough to act
|
||||||
|
* on, and it is the one both the affordance and the action read so they cannot drift.
|
||||||
|
*/
|
||||||
|
export function canDismissPaneInLayout(layout: WorkspaceLayout, paneId: string): boolean {
|
||||||
|
const pane = findPaneById(layout.root, paneId);
|
||||||
|
if (!pane || pane.hidden === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return collectAllPanes(layout.root).length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a pane outright, tabs and all. Callers own tab teardown (archiving
|
||||||
|
* agents, killing terminals) before calling this; the layout only forgets them.
|
||||||
|
*
|
||||||
|
* The split tree cannot represent a workspace with nothing in it, so the last
|
||||||
|
* visible pane never goes. `main` and the explorer pane are ordinary panes here
|
||||||
|
* — `closeTabInLayout` keeps them alive when they empty, this is how they leave.
|
||||||
|
*/
|
||||||
|
export function closePaneInLayout(input: ClosePaneInLayoutInput): WorkspaceLayout | null {
|
||||||
|
const layout = asInternalLayout(input.layout);
|
||||||
|
const panePath = findPanePathById(layout.root, input.paneId);
|
||||||
|
if (!panePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const visiblePaneIds = listPaneIds(layout.root);
|
||||||
|
if (visiblePaneIds.length <= 1 && visiblePaneIds.includes(input.paneId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackPaneId = findNearestSiblingPaneId(layout.root, input.paneId);
|
||||||
|
const nextRoot = removePaneByPath(layout.root, panePath);
|
||||||
|
|
||||||
|
return withNormalizedParentTabMap({
|
||||||
|
root: nextRoot,
|
||||||
|
focusedPaneId: getFocusedPaneIdAfterTabClose({
|
||||||
|
root: nextRoot,
|
||||||
|
focusedPaneId: layout.focusedPaneId,
|
||||||
|
fallbackPaneId,
|
||||||
|
}),
|
||||||
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null {
|
export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
const pane = findPaneContainingTab(layout.root, input.tabId);
|
const pane = findPaneContainingTab(layout.root, input.tabId);
|
||||||
@@ -1395,6 +1554,107 @@ export function retargetTabInLayout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replaces one pane-local slot and never claims an equivalent tab elsewhere. */
|
||||||
|
export function replaceTabTargetInLayout(
|
||||||
|
input: ReplaceTabTargetInLayoutInput,
|
||||||
|
): RetargetTabInLayoutResult | null {
|
||||||
|
const layout = asInternalLayout(input.layout);
|
||||||
|
if (!findPaneContainingTab(layout.root, input.tabId)) return null;
|
||||||
|
const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null;
|
||||||
|
if (currentTab && workspaceTabTargetsEqual(currentTab.target, input.target)) {
|
||||||
|
if (input.state === undefined) {
|
||||||
|
return { layout: input.layout, tabId: input.tabId };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabId: input.tabId,
|
||||||
|
layout: withNormalizedParentTabMap({
|
||||||
|
root: replaceTabInTree(layout.root, {
|
||||||
|
tabId: input.tabId,
|
||||||
|
nextTabId: input.tabId,
|
||||||
|
target: input.target,
|
||||||
|
state: input.state,
|
||||||
|
}),
|
||||||
|
focusedPaneId: layout.focusedPaneId,
|
||||||
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let tabId = input.createTabId();
|
||||||
|
if (currentTab?.target.kind === "draft") {
|
||||||
|
tabId = input.tabId;
|
||||||
|
} else if (input.target.kind === "draft") {
|
||||||
|
tabId = buildDeterministicWorkspaceTabId(input.target);
|
||||||
|
}
|
||||||
|
const parentTabIdByTabId = transferReplacedTabParent({
|
||||||
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
replacedTabId: input.tabId,
|
||||||
|
replacementTabId: tabId,
|
||||||
|
});
|
||||||
|
let state: JsonValue | undefined;
|
||||||
|
if (input.state !== undefined) {
|
||||||
|
state = input.state;
|
||||||
|
} else if (currentTab?.target.kind === input.target.kind) {
|
||||||
|
state = currentTab.state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabId,
|
||||||
|
layout: withNormalizedParentTabMap({
|
||||||
|
root: replaceTabInTree(layout.root, {
|
||||||
|
tabId: input.tabId,
|
||||||
|
nextTabId: tabId,
|
||||||
|
target: input.target,
|
||||||
|
state,
|
||||||
|
}),
|
||||||
|
focusedPaneId: layout.focusedPaneId,
|
||||||
|
parentTabIdByTabId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferReplacedTabParent(input: {
|
||||||
|
parentTabIdByTabId?: Record<string, string>;
|
||||||
|
replacedTabId: string;
|
||||||
|
replacementTabId: string;
|
||||||
|
}): Record<string, string> | undefined {
|
||||||
|
if (!input.parentTabIdByTabId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parentTabId = input.parentTabIdByTabId[input.replacedTabId];
|
||||||
|
const renamed: Record<string, string> = {};
|
||||||
|
for (const [childTabId, currentParentTabId] of Object.entries(input.parentTabIdByTabId)) {
|
||||||
|
if (childTabId === input.replacedTabId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
renamed[childTabId] =
|
||||||
|
currentParentTabId === input.replacedTabId ? input.replacementTabId : currentParentTabId;
|
||||||
|
}
|
||||||
|
if (parentTabId) {
|
||||||
|
renamed[input.replacementTabId] =
|
||||||
|
parentTabId === input.replacedTabId ? input.replacementTabId : parentTabId;
|
||||||
|
}
|
||||||
|
return Object.keys(renamed).length > 0 ? renamed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTabStateInLayout(input: {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
tabId: string;
|
||||||
|
state: JsonValue | undefined;
|
||||||
|
}): WorkspaceLayout | null {
|
||||||
|
const layout = asInternalLayout(input.layout);
|
||||||
|
const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === input.tabId);
|
||||||
|
if (!tab) return null;
|
||||||
|
return withNormalizedParentTabMap({
|
||||||
|
root: replaceTabInTree(layout.root, {
|
||||||
|
tabId: tab.tabId,
|
||||||
|
nextTabId: tab.tabId,
|
||||||
|
target: tab.target,
|
||||||
|
state: input.state,
|
||||||
|
}),
|
||||||
|
focusedPaneId: layout.focusedPaneId,
|
||||||
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function convertDraftToAgentInLayout(
|
export function convertDraftToAgentInLayout(
|
||||||
input: ConvertDraftToAgentInLayoutInput,
|
input: ConvertDraftToAgentInLayoutInput,
|
||||||
): ConvertDraftToAgentInLayoutResult | null {
|
): ConvertDraftToAgentInLayoutResult | null {
|
||||||
@@ -1558,7 +1818,12 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
|
|||||||
|
|
||||||
const detached = detachTabFromTree(layout.root, {
|
const detached = detachTabFromTree(layout.root, {
|
||||||
tabId: input.tabId,
|
tabId: input.tabId,
|
||||||
preserveEmptyPaneId: sourcePane.id === input.toPaneId ? input.toPaneId : null,
|
// Dragging a pane's last tab elsewhere collapses that pane — except the side
|
||||||
|
// panel, which the user summons and expects to find again, geometry and all.
|
||||||
|
preserveEmptyPaneId:
|
||||||
|
sourcePane.id === input.toPaneId || sourcePane.id === SIDE_PANEL_PANE_ID
|
||||||
|
? sourcePane.id
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
if (!detached.tab) {
|
if (!detached.tab) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1606,6 +1871,10 @@ export function setPaneHiddenInLayout(input: {
|
|||||||
if (!pane || isHidden === input.hidden) {
|
if (!pane || isHidden === input.hidden) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// A workspace always has somewhere to look. This is the single gate on hiding the
|
||||||
|
// last visible pane — `hideSidePanel` and `closePane` both land here, so neither
|
||||||
|
// needs its own check, and `collectAllPanes` skipping hidden panes is what makes
|
||||||
|
// "the only one left" mean visibly left.
|
||||||
if (input.hidden && collectAllPanes(input.layout.root).length === 1) {
|
if (input.hidden && collectAllPanes(input.layout.root).length === 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1771,13 +2040,15 @@ function isTerminalTab(
|
|||||||
function openEntityTabWithoutFocusing(input: {
|
function openEntityTabWithoutFocusing(input: {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
explorerPaneId: string | null;
|
sidePanelPaneId: string | null;
|
||||||
}): WorkspaceLayout {
|
}): WorkspaceLayout {
|
||||||
return insertNewTabIntoPane({
|
return insertNewTabIntoPane({
|
||||||
layout: input.layout,
|
layout: input.layout,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
explorerPaneId: input.explorerPaneId,
|
placement: AMBIENT_PLACEMENT,
|
||||||
|
sidePanelPaneId: input.sidePanelPaneId,
|
||||||
|
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
|
||||||
focus: false,
|
focus: false,
|
||||||
}).layout;
|
}).layout;
|
||||||
}
|
}
|
||||||
@@ -1869,7 +2140,7 @@ function addMissingEntityTabs(input: {
|
|||||||
standaloneTerminalIds: Set<string>;
|
standaloneTerminalIds: Set<string>;
|
||||||
hasActivePendingTerminalCreate: boolean;
|
hasActivePendingTerminalCreate: boolean;
|
||||||
hasActivePendingDraftCreate: boolean;
|
hasActivePendingDraftCreate: boolean;
|
||||||
explorerPaneId: string | null;
|
sidePanelPaneId: string | null;
|
||||||
}): WorkspaceLayout {
|
}): WorkspaceLayout {
|
||||||
const {
|
const {
|
||||||
autoOpenAgentIds,
|
autoOpenAgentIds,
|
||||||
@@ -1877,7 +2148,7 @@ function addMissingEntityTabs(input: {
|
|||||||
standaloneTerminalIds,
|
standaloneTerminalIds,
|
||||||
hasActivePendingTerminalCreate,
|
hasActivePendingTerminalCreate,
|
||||||
hasActivePendingDraftCreate,
|
hasActivePendingDraftCreate,
|
||||||
explorerPaneId,
|
sidePanelPaneId,
|
||||||
} = input;
|
} = input;
|
||||||
let nextLayout = input.layout;
|
let nextLayout = input.layout;
|
||||||
const currentEntityTabs = collectAllTabs(nextLayout.root);
|
const currentEntityTabs = collectAllTabs(nextLayout.root);
|
||||||
@@ -1899,7 +2170,7 @@ function addMissingEntityTabs(input: {
|
|||||||
nextLayout = openEntityTabWithoutFocusing({
|
nextLayout = openEntityTabWithoutFocusing({
|
||||||
layout: nextLayout,
|
layout: nextLayout,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
explorerPaneId,
|
sidePanelPaneId,
|
||||||
});
|
});
|
||||||
currentAgentIds.add(agentId);
|
currentAgentIds.add(agentId);
|
||||||
}
|
}
|
||||||
@@ -1913,7 +2184,7 @@ function addMissingEntityTabs(input: {
|
|||||||
nextLayout = openEntityTabWithoutFocusing({
|
nextLayout = openEntityTabWithoutFocusing({
|
||||||
layout: nextLayout,
|
layout: nextLayout,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
explorerPaneId,
|
sidePanelPaneId,
|
||||||
});
|
});
|
||||||
currentTerminalIds.add(terminalId);
|
currentTerminalIds.add(terminalId);
|
||||||
}
|
}
|
||||||
@@ -2006,7 +2277,7 @@ export function reconcileWorkspaceTabs(
|
|||||||
standaloneTerminalIds,
|
standaloneTerminalIds,
|
||||||
hasActivePendingTerminalCreate: snapshot.hasActivePendingTerminalCreate ?? false,
|
hasActivePendingTerminalCreate: snapshot.hasActivePendingTerminalCreate ?? false,
|
||||||
hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false,
|
hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false,
|
||||||
explorerPaneId: state.explorerPaneId,
|
sidePanelPaneId: state.sidePanelPaneId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (reconciledFocusedTabId) {
|
if (reconciledFocusedTabId) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,26 @@ import { useEffect, useState } from "react";
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import {
|
import {
|
||||||
defaultWorkspaceLayoutIds,
|
defaultWorkspaceLayoutIds,
|
||||||
type WorkspaceLayoutIdSource,
|
type WorkspaceLayoutIdSource,
|
||||||
} from "@/stores/workspace-layout-ids";
|
} from "@/stores/workspace-layout-ids";
|
||||||
import {
|
import {
|
||||||
|
canDismissPaneInLayout,
|
||||||
clampNormalizedSizes,
|
clampNormalizedSizes,
|
||||||
|
closePaneInLayout,
|
||||||
closeTabInLayout,
|
closeTabInLayout,
|
||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
convertDraftToAgentInLayout,
|
convertDraftToAgentInLayout,
|
||||||
|
createTabInLayout,
|
||||||
createDefaultLayout,
|
createDefaultLayout,
|
||||||
createWorkspaceLayoutWithExplorer,
|
AMBIENT_PLACEMENT,
|
||||||
DEFAULT_EXPLORER_PANE_ID,
|
createWorkspaceLayoutWithSidePanel,
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
|
SIDE_PANEL_PANE_ID,
|
||||||
findPaneById,
|
findPaneById,
|
||||||
findPaneContainingTab,
|
findPaneContainingTab,
|
||||||
focusPaneInLayout,
|
focusPaneInLayout,
|
||||||
@@ -27,20 +33,22 @@ import {
|
|||||||
moveTabToPaneInLayout,
|
moveTabToPaneInLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
openTabInLayoutBackground,
|
openTabInLayoutBackground,
|
||||||
openTabInLayoutFocused,
|
replaceTabTargetInLayout,
|
||||||
|
revealTargetInLayout,
|
||||||
reconcileWorkspaceTabs,
|
reconcileWorkspaceTabs,
|
||||||
removePaneFromTree,
|
removePaneFromTree,
|
||||||
removeTabFromTree,
|
removeTabFromTree,
|
||||||
reorderFocusedPaneTabsInLayout,
|
reorderFocusedPaneTabsInLayout,
|
||||||
reorderPaneTabsInLayout,
|
reorderPaneTabsInLayout,
|
||||||
retargetTabInLayout,
|
|
||||||
setPaneHiddenInLayout,
|
setPaneHiddenInLayout,
|
||||||
|
setTabStateInLayout,
|
||||||
splitPaneEmptyInLayout,
|
splitPaneEmptyInLayout,
|
||||||
splitPaneInLayout,
|
splitPaneInLayout,
|
||||||
stripEphemeralTabsFromLayout,
|
stripEphemeralTabsFromLayout,
|
||||||
type SplitGroup,
|
type SplitGroup,
|
||||||
type SplitNode,
|
type SplitNode,
|
||||||
type SplitPane,
|
type SplitPane,
|
||||||
|
type WorkspaceTabPlacement,
|
||||||
type WorkspaceTabReconcileState,
|
type WorkspaceTabReconcileState,
|
||||||
type WorkspaceTabSnapshot,
|
type WorkspaceTabSnapshot,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
@@ -49,10 +57,13 @@ import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
|||||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
AMBIENT_PLACEMENT,
|
||||||
|
canDismissPaneInLayout,
|
||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
createDefaultLayout,
|
createDefaultLayout,
|
||||||
createWorkspaceLayoutWithExplorer,
|
createWorkspaceLayoutWithSidePanel,
|
||||||
|
FOCUSED_PANE_PLACEMENT,
|
||||||
findPaneById,
|
findPaneById,
|
||||||
findPaneContainingTab,
|
findPaneContainingTab,
|
||||||
getFocusedBrowserId,
|
getFocusedBrowserId,
|
||||||
@@ -68,10 +79,21 @@ export type {
|
|||||||
SplitNode,
|
SplitNode,
|
||||||
SplitPane,
|
SplitPane,
|
||||||
WorkspaceLayout,
|
WorkspaceLayout,
|
||||||
|
WorkspaceTabPlacement,
|
||||||
WorkspaceTabReconcileState,
|
WorkspaceTabReconcileState,
|
||||||
WorkspaceTabSnapshot,
|
WorkspaceTabSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceTabOpenIntent = "new" | "reveal" | "background";
|
||||||
|
export interface OpenWorkspaceTabInput {
|
||||||
|
workspaceKey: string;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
intent: WorkspaceTabOpenIntent;
|
||||||
|
placement?: WorkspaceTabPlacement;
|
||||||
|
parentTabId?: string;
|
||||||
|
state?: JsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkspaceLayoutStore {
|
interface WorkspaceLayoutStore {
|
||||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||||
@@ -79,43 +101,20 @@ interface WorkspaceLayoutStore {
|
|||||||
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||||
explorerPaneIdByWorkspace: Record<string, string | null>;
|
sidePanelPaneIdByWorkspace: Record<string, string | null>;
|
||||||
acknowledgedPullRequestByWorkspace: Record<string, string>;
|
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||||
openTabFocused: (
|
/** Reveals the side panel without putting anything in it. Returns its pane id. */
|
||||||
workspaceKey: string,
|
showSidePanel: (workspaceKey: string) => string | null;
|
||||||
target: WorkspaceTabTarget,
|
hideSidePanel: (workspaceKey: string) => void;
|
||||||
options?: WorkspaceTabOpenOptions,
|
|
||||||
) => string | null;
|
|
||||||
openTabInFocusedPane: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
|
||||||
openChildTabFocused: (
|
|
||||||
workspaceKey: string,
|
|
||||||
target: WorkspaceTabTarget,
|
|
||||||
parentTabId: string,
|
|
||||||
options?: WorkspaceTabOpenOptions,
|
|
||||||
) => string | null;
|
|
||||||
openChildTabInFocusedPane: (
|
|
||||||
workspaceKey: string,
|
|
||||||
target: WorkspaceTabTarget,
|
|
||||||
parentTabId: string,
|
|
||||||
) => string | null;
|
|
||||||
openTabInBackground: (
|
|
||||||
workspaceKey: string,
|
|
||||||
target: WorkspaceTabTarget,
|
|
||||||
options?: WorkspaceTabOpenOptions,
|
|
||||||
) => string | null;
|
|
||||||
openTabInExplorerPaneBackground: (
|
|
||||||
workspaceKey: string,
|
|
||||||
target: WorkspaceTabTarget,
|
|
||||||
) => string | null;
|
|
||||||
ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null;
|
|
||||||
openTabInExplorerPaneFocused: (
|
|
||||||
workspaceKey: string,
|
|
||||||
input: { target: WorkspaceTabTarget; parentTabId?: string | null },
|
|
||||||
) => string | null;
|
|
||||||
observePullRequest: (workspaceKey: string, identity: string | null) => void;
|
|
||||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
replaceTab: (
|
||||||
|
workspaceKey: string,
|
||||||
|
tabId: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
state?: JsonValue,
|
||||||
|
) => string | null;
|
||||||
|
setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void;
|
||||||
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
||||||
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
||||||
resolvePendingAgent: (workspaceKey: string, agentId: string) => void;
|
resolvePendingAgent: (workspaceKey: string, agentId: string) => void;
|
||||||
@@ -137,10 +136,13 @@ interface WorkspaceLayoutStore {
|
|||||||
},
|
},
|
||||||
) => string | null;
|
) => string | null;
|
||||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||||
|
/**
|
||||||
|
* Dismisses the pane along with whatever it still holds. The side panel hides so
|
||||||
|
* the user can bring it back; every other pane is removed. Callers own tab teardown
|
||||||
|
* (archiving agents, killing terminals) first. No-ops on the last visible pane.
|
||||||
|
*/
|
||||||
|
closePane: (workspaceKey: string, paneId: string) => void;
|
||||||
focusPane: (workspaceKey: string, paneId: string) => void;
|
focusPane: (workspaceKey: string, paneId: string) => void;
|
||||||
hidePane: (workspaceKey: string, paneId: string) => void;
|
|
||||||
showPane: (workspaceKey: string, paneId: string) => void;
|
|
||||||
setExplorerPaneId: (workspaceKey: string, paneId: string | null) => void;
|
|
||||||
unfocusPane: (workspaceKey: string) => string | null;
|
unfocusPane: (workspaceKey: string) => string | null;
|
||||||
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
||||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||||
@@ -152,22 +154,12 @@ interface WorkspaceLayoutStore {
|
|||||||
purgeWorkspace: (workspaceKey: string) => void;
|
purgeWorkspace: (workspaceKey: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `paneId` is explicit placement for a pane-local affordance; omitted opens use policy. */
|
|
||||||
export interface WorkspaceTabOpenOptions {
|
|
||||||
paneId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnsureExplorerPaneResult {
|
|
||||||
paneId: string;
|
|
||||||
created: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceFocusRestorationState {
|
interface WorkspaceFocusRestorationState {
|
||||||
restorePaneId: string | null;
|
restorePaneId: string | null;
|
||||||
tokens: string[];
|
tokens: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The root companion split is always present for the hidden explorer pane.
|
// The root companion split is always present for the hidden side panel pane.
|
||||||
// Preserve four user-created split levels beneath it.
|
// Preserve four user-created split levels beneath it.
|
||||||
const MAX_TREE_DEPTH = 5;
|
const MAX_TREE_DEPTH = 5;
|
||||||
|
|
||||||
@@ -232,6 +224,7 @@ const WorkspaceTabStorageSchema = z.strictObject({
|
|||||||
tabId: z.string(),
|
tabId: z.string(),
|
||||||
target: WorkspaceTabTargetStorageSchema,
|
target: WorkspaceTabTargetStorageSchema,
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
|
state: z.json().optional(),
|
||||||
});
|
});
|
||||||
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
|
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
|
||||||
z.discriminatedUnion("kind", [
|
z.discriminatedUnion("kind", [
|
||||||
@@ -264,7 +257,11 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
|
|||||||
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
|
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
|
||||||
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
|
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
|
||||||
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
|
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
|
||||||
|
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
|
||||||
|
// rename here would fail every existing blob and wipe the layout it describes.
|
||||||
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
|
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
|
||||||
|
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
|
||||||
|
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
|
||||||
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
|
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +273,14 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWorkspaceTabInstanceId(): string {
|
||||||
|
const value =
|
||||||
|
typeof globalThis.crypto?.randomUUID === "function"
|
||||||
|
? globalThis.crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
return `tab_${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
function addAgentIdToWorkspaceSet(
|
function addAgentIdToWorkspaceSet(
|
||||||
state: Record<string, Set<string>>,
|
state: Record<string, Set<string>>,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
@@ -322,10 +327,33 @@ function getWorkspaceLayout(
|
|||||||
state: Record<string, WorkspaceLayout>,
|
state: Record<string, WorkspaceLayout>,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
): WorkspaceLayout {
|
): WorkspaceLayout {
|
||||||
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorer());
|
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithSidePanel());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveExplorerPaneId(
|
type SidePanelState = Pick<
|
||||||
|
WorkspaceLayoutStore,
|
||||||
|
"layoutByWorkspace" | "sidePanelPaneIdByWorkspace"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The side panel's pane, on screen or not. A workspace the user has not laid out
|
||||||
|
* yet still has one — the default layout is born with it — so this answers before
|
||||||
|
* the first tab exists.
|
||||||
|
*/
|
||||||
|
export function selectSidePanelPaneId(state: SidePanelState, workspaceKey: string): string | null {
|
||||||
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
||||||
|
return resolveSidePanelPaneId(layout, state.sidePanelPaneIdByWorkspace[workspaceKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the side panel pane is currently on screen. */
|
||||||
|
export function selectIsSidePanelVisible(state: SidePanelState, workspaceKey: string): boolean {
|
||||||
|
const layout = state.layoutByWorkspace[workspaceKey];
|
||||||
|
const paneId = layout ? selectSidePanelPaneId(state, workspaceKey) : null;
|
||||||
|
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
|
||||||
|
return Boolean(pane && pane.hidden !== true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSidePanelPaneId(
|
||||||
layout: WorkspaceLayout,
|
layout: WorkspaceLayout,
|
||||||
registeredPaneId: string | null | undefined,
|
registeredPaneId: string | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -333,16 +361,16 @@ export function resolveExplorerPaneId(
|
|||||||
if (registeredPane) {
|
if (registeredPane) {
|
||||||
return registeredPane.id;
|
return registeredPane.id;
|
||||||
}
|
}
|
||||||
const defaultPane = findPaneById(layout.root, DEFAULT_EXPLORER_PANE_ID);
|
const defaultPane = findPaneById(layout.root, SIDE_PANEL_PANE_ID);
|
||||||
return defaultPane?.id ?? null;
|
return defaultPane?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePersistedExplorerPane(input: {
|
function ensurePersistedSidePanelPane(input: {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
registeredPaneId: string | null | undefined;
|
registeredPaneId: string | null | undefined;
|
||||||
ids: WorkspaceLayoutIdSource;
|
ids: WorkspaceLayoutIdSource;
|
||||||
}): { layout: WorkspaceLayout; paneId: string } | null {
|
}): { layout: WorkspaceLayout; paneId: string } | null {
|
||||||
const existingPaneId = resolveExplorerPaneId(input.layout, input.registeredPaneId);
|
const existingPaneId = resolveSidePanelPaneId(input.layout, input.registeredPaneId);
|
||||||
if (existingPaneId) {
|
if (existingPaneId) {
|
||||||
return { layout: input.layout, paneId: existingPaneId };
|
return { layout: input.layout, paneId: existingPaneId };
|
||||||
}
|
}
|
||||||
@@ -373,13 +401,17 @@ function ensurePersistedExplorerPane(input: {
|
|||||||
function getOpenTabPlacement(
|
function getOpenTabPlacement(
|
||||||
state: WorkspaceLayoutStore,
|
state: WorkspaceLayoutStore,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
options: WorkspaceTabOpenOptions | undefined,
|
placement: WorkspaceTabPlacement | undefined,
|
||||||
): { layout: WorkspaceLayout; paneId: string | null; explorerPaneId: string | null } {
|
): {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
placement: WorkspaceTabPlacement;
|
||||||
|
sidePanelPaneId: string | null;
|
||||||
|
} {
|
||||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
||||||
return {
|
return {
|
||||||
layout,
|
layout,
|
||||||
paneId: trimNonEmpty(options?.paneId),
|
placement: placement ?? AMBIENT_PLACEMENT,
|
||||||
explorerPaneId: resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[workspaceKey]),
|
sidePanelPaneId: resolveSidePanelPaneId(layout, state.sidePanelPaneIdByWorkspace[workspaceKey]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +452,20 @@ function attachParentTab(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a side panel out of the pane the user is in. Only reached by layouts saved
|
||||||
|
* before the side panel became part of the default tree; new ones are born with it.
|
||||||
|
*/
|
||||||
|
function createSidePanelPane(
|
||||||
|
workspaceKey: string,
|
||||||
|
layout: WorkspaceLayout,
|
||||||
|
splitPaneEmpty: WorkspaceLayoutStore["splitPaneEmpty"],
|
||||||
|
): string | null {
|
||||||
|
const targetPaneId =
|
||||||
|
findPaneById(layout.root, layout.focusedPaneId)?.id ?? collectAllPanes(layout.root)[0]?.id;
|
||||||
|
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function createWorkspaceLayoutStore(
|
export function createWorkspaceLayoutStore(
|
||||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||||
) {
|
) {
|
||||||
@@ -432,21 +478,37 @@ export function createWorkspaceLayoutStore(
|
|||||||
pendingAgentIdsByWorkspace: {},
|
pendingAgentIdsByWorkspace: {},
|
||||||
hiddenAgentIdsByWorkspace: {},
|
hiddenAgentIdsByWorkspace: {},
|
||||||
focusRestorationByWorkspace: {},
|
focusRestorationByWorkspace: {},
|
||||||
explorerPaneIdByWorkspace: {},
|
sidePanelPaneIdByWorkspace: {},
|
||||||
acknowledgedPullRequestByWorkspace: {},
|
openTab: (input) => {
|
||||||
openTabFocused: (workspaceKey, target, options) => {
|
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const placement = getOpenTabPlacement(get(), normalizedWorkspaceKey, input.placement);
|
||||||
const result = openTabInLayoutFocused({
|
let result;
|
||||||
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
|
if (input.intent === "new") {
|
||||||
target: normalizedTarget,
|
result = createTabInLayout({
|
||||||
now: Date.now(),
|
...placement,
|
||||||
});
|
target: normalizedTarget,
|
||||||
|
now: Date.now(),
|
||||||
|
createTabId: createWorkspaceTabInstanceId,
|
||||||
|
state: input.state,
|
||||||
|
});
|
||||||
|
} else if (input.intent === "background") {
|
||||||
|
result = openTabInLayoutBackground({
|
||||||
|
...placement,
|
||||||
|
target: normalizedTarget,
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = revealTargetInLayout({
|
||||||
|
...placement,
|
||||||
|
target: normalizedTarget,
|
||||||
|
now: Date.now(),
|
||||||
|
createTabId: createWorkspaceTabInstanceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
hiddenAgentIdsByWorkspace:
|
hiddenAgentIdsByWorkspace:
|
||||||
@@ -459,244 +521,73 @@ export function createWorkspaceLayoutStore(
|
|||||||
),
|
),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: result.layout,
|
[normalizedWorkspaceKey]: input.parentTabId
|
||||||
|
? attachParentTab({
|
||||||
|
layout: result.layout,
|
||||||
|
childTabId: result.tabId,
|
||||||
|
parentTabId: input.parentTabId,
|
||||||
|
})
|
||||||
|
: result.layout,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result.tabId;
|
return result.tabId;
|
||||||
},
|
},
|
||||||
openTabInFocusedPane: (workspaceKey, target) => {
|
showSidePanel: (workspaceKey) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
if (!normalizedWorkspaceKey) {
|
if (!normalizedWorkspaceKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
||||||
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
|
const paneId =
|
||||||
return get().openTabFocused(normalizedWorkspaceKey, target, {
|
resolveSidePanelPaneId(
|
||||||
paneId: focusedPane?.hidden === true ? null : focusedPane?.id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openChildTabFocused: (workspaceKey, target, parentTabId, options) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
const normalizedParentTabId = trimNonEmpty(parentTabId);
|
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedParentTabId || !normalizedTarget) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = openTabInLayoutFocused({
|
|
||||||
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
|
|
||||||
target: normalizedTarget,
|
|
||||||
now: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const layout = attachParentTab({
|
|
||||||
layout: result.layout,
|
|
||||||
childTabId: result.tabId,
|
|
||||||
parentTabId: normalizedParentTabId,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
||||||
hiddenAgentIdsByWorkspace:
|
|
||||||
normalizedTarget.kind !== "agent"
|
|
||||||
? state.hiddenAgentIdsByWorkspace
|
|
||||||
: removeAgentIdFromWorkspaceSet(
|
|
||||||
state.hiddenAgentIdsByWorkspace,
|
|
||||||
normalizedWorkspaceKey,
|
|
||||||
normalizedTarget.agentId,
|
|
||||||
),
|
|
||||||
layoutByWorkspace: {
|
|
||||||
...state.layoutByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: layout,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.tabId;
|
|
||||||
},
|
|
||||||
openChildTabInFocusedPane: (workspaceKey, target, parentTabId) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
if (!normalizedWorkspaceKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
||||||
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
|
|
||||||
return get().openChildTabFocused(normalizedWorkspaceKey, target, parentTabId, {
|
|
||||||
paneId: focusedPane?.hidden === true ? null : focusedPane?.id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openTabInBackground: (workspaceKey, target, options) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = openTabInLayoutBackground({
|
|
||||||
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
|
|
||||||
target: normalizedTarget,
|
|
||||||
now: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state) => ({
|
|
||||||
hiddenAgentIdsByWorkspace:
|
|
||||||
normalizedTarget.kind !== "agent"
|
|
||||||
? state.hiddenAgentIdsByWorkspace
|
|
||||||
: removeAgentIdFromWorkspaceSet(
|
|
||||||
state.hiddenAgentIdsByWorkspace,
|
|
||||||
normalizedWorkspaceKey,
|
|
||||||
normalizedTarget.agentId,
|
|
||||||
),
|
|
||||||
layoutByWorkspace: {
|
|
||||||
...state.layoutByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: result.layout,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return result.tabId;
|
|
||||||
},
|
|
||||||
openTabInExplorerPaneBackground: (workspaceKey, target) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tabId: string | null = null;
|
|
||||||
set((state) => {
|
|
||||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
||||||
const explorerPaneId = resolveExplorerPaneId(
|
|
||||||
layout,
|
layout,
|
||||||
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
get().sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||||
);
|
) ?? createSidePanelPane(normalizedWorkspaceKey, layout, get().splitPaneEmpty);
|
||||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
|
||||||
if (!explorerPane) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new tab is placed straight into the explorer pane, hidden or not.
|
|
||||||
// A tab already open elsewhere stays where the user put it — this open
|
|
||||||
// must not move, reveal, or focus anything.
|
|
||||||
const result = openTabInLayoutBackground({
|
|
||||||
layout,
|
|
||||||
target: normalizedTarget,
|
|
||||||
now: Date.now(),
|
|
||||||
paneId: explorerPane.id,
|
|
||||||
explorerPaneId: explorerPane.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
tabId = result.tabId;
|
|
||||||
return {
|
|
||||||
explorerPaneIdByWorkspace: {
|
|
||||||
...state.explorerPaneIdByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: explorerPane.id,
|
|
||||||
},
|
|
||||||
layoutByWorkspace: {
|
|
||||||
...state.layoutByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: result.layout,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return tabId;
|
|
||||||
},
|
|
||||||
ensureExplorerPane: (workspaceKey) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
if (!normalizedWorkspaceKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = get();
|
|
||||||
const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey);
|
|
||||||
const explorerPaneId = resolveExplorerPaneId(
|
|
||||||
layout,
|
|
||||||
store.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
||||||
);
|
|
||||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
|
||||||
if (explorerPane) {
|
|
||||||
if (store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] !== explorerPane.id) {
|
|
||||||
store.setExplorerPaneId(normalizedWorkspaceKey, explorerPane.id);
|
|
||||||
}
|
|
||||||
store.focusPane(normalizedWorkspaceKey, explorerPane.id);
|
|
||||||
return { paneId: explorerPane.id, created: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPaneId =
|
|
||||||
findPaneById(layout.root, layout.focusedPaneId)?.id ??
|
|
||||||
collectAllPanes(layout.root)[0]?.id;
|
|
||||||
if (!targetPaneId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const paneId = store.splitPaneEmpty(normalizedWorkspaceKey, {
|
|
||||||
targetPaneId,
|
|
||||||
position: "right",
|
|
||||||
});
|
|
||||||
if (!paneId) {
|
if (!paneId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
get().setExplorerPaneId(normalizedWorkspaceKey, paneId);
|
|
||||||
return { paneId, created: true };
|
|
||||||
},
|
|
||||||
openTabInExplorerPaneFocused: (workspaceKey, input) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = get();
|
set((state) =>
|
||||||
const explorerPane = store.ensureExplorerPane(normalizedWorkspaceKey);
|
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey] === paneId
|
||||||
if (!explorerPane) {
|
? state
|
||||||
return null;
|
: {
|
||||||
}
|
sidePanelPaneIdByWorkspace: {
|
||||||
const parentTabId = trimNonEmpty(input.parentTabId);
|
...state.sidePanelPaneIdByWorkspace,
|
||||||
const placement = { paneId: explorerPane.paneId };
|
[normalizedWorkspaceKey]: paneId,
|
||||||
const tabId = parentTabId
|
},
|
||||||
? get().openChildTabFocused(
|
},
|
||||||
normalizedWorkspaceKey,
|
);
|
||||||
normalizedTarget,
|
// Focusing a hidden pane reveals it, so this is both halves of "show".
|
||||||
parentTabId,
|
get().focusPane(normalizedWorkspaceKey, paneId);
|
||||||
placement,
|
return paneId;
|
||||||
)
|
|
||||||
: get().openTabFocused(normalizedWorkspaceKey, normalizedTarget, placement);
|
|
||||||
if (!tabId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
||||||
const currentPane = findPaneContainingTab(layout.root, tabId);
|
|
||||||
if (currentPane?.id !== explorerPane.paneId) {
|
|
||||||
get().moveTabToPane(normalizedWorkspaceKey, tabId, explorerPane.paneId);
|
|
||||||
}
|
|
||||||
return tabId;
|
|
||||||
},
|
},
|
||||||
observePullRequest: (workspaceKey, identity) => {
|
hideSidePanel: (workspaceKey) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedIdentity = trimNonEmpty(identity);
|
if (!normalizedWorkspaceKey) {
|
||||||
if (!normalizedWorkspaceKey || !normalizedIdentity) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
set((state) => {
|
||||||
get().acknowledgedPullRequestByWorkspace[normalizedWorkspaceKey] === normalizedIdentity
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||||
) {
|
const paneId = resolveSidePanelPaneId(
|
||||||
return;
|
layout,
|
||||||
}
|
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||||
// A detected pull request is a background add: the tab appears in the
|
);
|
||||||
// explorer pane, but nothing is revealed and focus never moves.
|
const nextLayout = paneId
|
||||||
const tabId = get().openTabInExplorerPaneBackground(normalizedWorkspaceKey, {
|
? setPaneHiddenInLayout({ layout, paneId, hidden: true })
|
||||||
kind: "pull_request",
|
: null;
|
||||||
|
if (!nextLayout) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
if (!tabId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => ({
|
|
||||||
acknowledgedPullRequestByWorkspace: {
|
|
||||||
...state.acknowledgedPullRequestByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: normalizedIdentity,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
closeTab: (workspaceKey, tabId) => {
|
closeTab: (workspaceKey, tabId) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
@@ -707,20 +598,28 @@ export function createWorkspaceLayoutStore(
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||||
const explorerPaneId = resolveExplorerPaneId(
|
const sidePanelPaneId = resolveSidePanelPaneId(
|
||||||
layout,
|
layout,
|
||||||
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||||
);
|
);
|
||||||
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
|
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
|
||||||
const preserveEmptyPaneId =
|
const preserveEmptyPaneId =
|
||||||
closingPane?.id === "main" || closingPane?.id === explorerPaneId
|
closingPane?.id === "main" || closingPane?.id === sidePanelPaneId
|
||||||
? closingPane.id
|
? closingPane.id
|
||||||
: null;
|
: null;
|
||||||
const nextLayout = closeTabInLayout({
|
const closedLayout = closeTabInLayout({
|
||||||
layout,
|
layout,
|
||||||
tabId: normalizedTabId,
|
tabId: normalizedTabId,
|
||||||
preserveEmptyPaneId,
|
preserveEmptyPaneId,
|
||||||
});
|
});
|
||||||
|
const nextLayout =
|
||||||
|
closedLayout && closingPane?.id === sidePanelPaneId && closingPane.tabIds.length === 1
|
||||||
|
? (setPaneHiddenInLayout({
|
||||||
|
layout: closedLayout,
|
||||||
|
paneId: sidePanelPaneId,
|
||||||
|
hidden: true,
|
||||||
|
}) ?? closedLayout)
|
||||||
|
: closedLayout;
|
||||||
if (!nextLayout) {
|
if (!nextLayout) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -759,27 +658,21 @@ export function createWorkspaceLayoutStore(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
retargetTab: (workspaceKey, tabId, target) => {
|
replaceTab: (workspaceKey, tabId, target, tabState) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedTabId = trimNonEmpty(tabId);
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) {
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
||||||
return null;
|
const result = replaceTabTargetInLayout({
|
||||||
}
|
|
||||||
|
|
||||||
const result = retargetTabInLayout({
|
|
||||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||||
tabId: normalizedTabId,
|
tabId: normalizedTabId,
|
||||||
target: normalizedTarget,
|
target: normalizedTarget,
|
||||||
|
createTabId: createWorkspaceTabInstanceId,
|
||||||
|
state: tabState,
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...(result.layout.focusedPaneId !== null
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
|
||||||
: {}),
|
|
||||||
hiddenAgentIdsByWorkspace:
|
hiddenAgentIdsByWorkspace:
|
||||||
normalizedTarget.kind !== "agent"
|
normalizedTarget.kind !== "agent"
|
||||||
? state.hiddenAgentIdsByWorkspace
|
? state.hiddenAgentIdsByWorkspace
|
||||||
@@ -793,9 +686,27 @@ export function createWorkspaceLayoutStore(
|
|||||||
[normalizedWorkspaceKey]: result.layout,
|
[normalizedWorkspaceKey]: result.layout,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result.tabId;
|
return result.tabId;
|
||||||
},
|
},
|
||||||
|
setTabState: (workspaceKey, tabId, tabState) => {
|
||||||
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
|
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
||||||
|
set((state) => {
|
||||||
|
const layout = setTabStateInLayout({
|
||||||
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||||
|
tabId: normalizedTabId,
|
||||||
|
state: tabState,
|
||||||
|
});
|
||||||
|
if (!layout) return state;
|
||||||
|
return {
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[normalizedWorkspaceKey]: layout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
|
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedTabId = trimNonEmpty(tabId);
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
@@ -847,9 +758,9 @@ export function createWorkspaceLayoutStore(
|
|||||||
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||||
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||||
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||||
explorerPaneId: resolveExplorerPaneId(
|
sidePanelPaneId: resolveSidePanelPaneId(
|
||||||
currentLayout,
|
currentLayout,
|
||||||
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -1002,6 +913,38 @@ export function createWorkspaceLayoutStore(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
closePane: (workspaceKey, paneId) => {
|
||||||
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
|
const normalizedPaneId = trimNonEmpty(paneId);
|
||||||
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||||
|
// The side panel is a surface the user summons, so closing it puts it
|
||||||
|
// away rather than dismantling the split it lives in.
|
||||||
|
const isSidePanel =
|
||||||
|
resolveSidePanelPaneId(
|
||||||
|
layout,
|
||||||
|
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||||
|
) === normalizedPaneId;
|
||||||
|
const nextLayout = isSidePanel
|
||||||
|
? setPaneHiddenInLayout({ layout, paneId: normalizedPaneId, hidden: true })
|
||||||
|
: closePaneInLayout({ layout, paneId: normalizedPaneId });
|
||||||
|
if (!nextLayout) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
focusPane: (workspaceKey, paneId) => {
|
focusPane: (workspaceKey, paneId) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedPaneId = trimNonEmpty(paneId);
|
const normalizedPaneId = trimNonEmpty(paneId);
|
||||||
@@ -1027,67 +970,6 @@ export function createWorkspaceLayoutStore(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
hidePane: (workspaceKey, paneId) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
const normalizedPaneId = trimNonEmpty(paneId);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const nextLayout = setPaneHiddenInLayout({
|
|
||||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
||||||
paneId: normalizedPaneId,
|
|
||||||
hidden: true,
|
|
||||||
});
|
|
||||||
if (!nextLayout) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
||||||
layoutByWorkspace: {
|
|
||||||
...state.layoutByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
showPane: (workspaceKey, paneId) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
const normalizedPaneId = trimNonEmpty(paneId);
|
|
||||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const nextLayout = setPaneHiddenInLayout({
|
|
||||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
||||||
paneId: normalizedPaneId,
|
|
||||||
hidden: false,
|
|
||||||
});
|
|
||||||
if (!nextLayout) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
layoutByWorkspace: {
|
|
||||||
...state.layoutByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
setExplorerPaneId: (workspaceKey, paneId) => {
|
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
||||||
if (!normalizedWorkspaceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
set((state) => ({
|
|
||||||
explorerPaneIdByWorkspace: {
|
|
||||||
...state.explorerPaneIdByWorkspace,
|
|
||||||
[normalizedWorkspaceKey]: trimNonEmpty(paneId),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
unfocusPane: (workspaceKey) => {
|
unfocusPane: (workspaceKey) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
if (!normalizedWorkspaceKey) {
|
if (!normalizedWorkspaceKey) {
|
||||||
@@ -1359,8 +1241,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
|
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.explorerPaneIdByWorkspace ||
|
normalizedWorkspaceKey in state.sidePanelPaneIdByWorkspace;
|
||||||
normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace;
|
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -1376,12 +1257,8 @@ export function createWorkspaceLayoutStore(
|
|||||||
state.hiddenAgentIdsByWorkspace;
|
state.hiddenAgentIdsByWorkspace;
|
||||||
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||||
state.focusRestorationByWorkspace;
|
state.focusRestorationByWorkspace;
|
||||||
const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } =
|
const { [normalizedWorkspaceKey]: _sidePanelPane, ...sidePanelPaneIdByWorkspace } =
|
||||||
state.explorerPaneIdByWorkspace;
|
state.sidePanelPaneIdByWorkspace;
|
||||||
const {
|
|
||||||
[normalizedWorkspaceKey]: _acknowledgedPullRequest,
|
|
||||||
...acknowledgedPullRequestByWorkspace
|
|
||||||
} = state.acknowledgedPullRequestByWorkspace;
|
|
||||||
return {
|
return {
|
||||||
layoutByWorkspace,
|
layoutByWorkspace,
|
||||||
splitSizesByWorkspace,
|
splitSizesByWorkspace,
|
||||||
@@ -1389,8 +1266,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
pendingAgentIdsByWorkspace,
|
pendingAgentIdsByWorkspace,
|
||||||
hiddenAgentIdsByWorkspace,
|
hiddenAgentIdsByWorkspace,
|
||||||
focusRestorationByWorkspace,
|
focusRestorationByWorkspace,
|
||||||
explorerPaneIdByWorkspace,
|
sidePanelPaneIdByWorkspace,
|
||||||
acknowledgedPullRequestByWorkspace,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1411,8 +1287,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
return {
|
return {
|
||||||
layoutByWorkspace,
|
layoutByWorkspace,
|
||||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||||
explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace,
|
explorerPaneIdByWorkspace: state.sidePanelPaneIdByWorkspace,
|
||||||
acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => {
|
||||||
@@ -1421,31 +1296,29 @@ export function createWorkspaceLayoutStore(
|
|||||||
return currentState;
|
return currentState;
|
||||||
}
|
}
|
||||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||||
const explorerPaneIdByWorkspace: Record<string, string | null> = {
|
const sidePanelPaneIdByWorkspace: Record<string, string | null> = {
|
||||||
...result.data.explorerPaneIdByWorkspace,
|
...result.data.explorerPaneIdByWorkspace,
|
||||||
};
|
};
|
||||||
for (const [workspaceKey, persistedLayout] of Object.entries(
|
for (const [workspaceKey, persistedLayout] of Object.entries(
|
||||||
result.data.layoutByWorkspace,
|
result.data.layoutByWorkspace,
|
||||||
)) {
|
)) {
|
||||||
const explorer = ensurePersistedExplorerPane({
|
const sidePanel = ensurePersistedSidePanelPane({
|
||||||
layout: normalizeLayout(persistedLayout),
|
layout: normalizeLayout(persistedLayout),
|
||||||
registeredPaneId: explorerPaneIdByWorkspace[workspaceKey],
|
registeredPaneId: sidePanelPaneIdByWorkspace[workspaceKey],
|
||||||
ids,
|
ids,
|
||||||
});
|
});
|
||||||
if (!explorer) {
|
if (!sidePanel) {
|
||||||
layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout);
|
layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
layoutByWorkspace[workspaceKey] = explorer.layout;
|
layoutByWorkspace[workspaceKey] = sidePanel.layout;
|
||||||
explorerPaneIdByWorkspace[workspaceKey] = explorer.paneId;
|
sidePanelPaneIdByWorkspace[workspaceKey] = sidePanel.paneId;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
layoutByWorkspace,
|
layoutByWorkspace,
|
||||||
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
||||||
explorerPaneIdByWorkspace,
|
sidePanelPaneIdByWorkspace,
|
||||||
acknowledgedPullRequestByWorkspace:
|
|
||||||
result.data.acknowledgedPullRequestByWorkspace ?? {},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ describe("workspace subagents integration", () => {
|
|||||||
).toEqual([child.id]);
|
).toEqual([child.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens a subagent in the explorer pane when focused", () => {
|
it("opens a subagent in the side panel when focused", () => {
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
workspaceId: WORKSPACE_ID,
|
workspaceId: WORKSPACE_ID,
|
||||||
@@ -275,22 +275,26 @@ describe("workspace subagents integration", () => {
|
|||||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||||
|
|
||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
const tabId = store.openTabInExplorerPaneFocused(workspaceKey!, {
|
const paneId = store.showSidePanel(workspaceKey!) as string;
|
||||||
|
const tabId = store.openTab({
|
||||||
|
workspaceKey: workspaceKey!,
|
||||||
target: { kind: "agent", agentId: child.id },
|
target: { kind: "agent", agentId: child.id },
|
||||||
|
intent: "reveal",
|
||||||
parentTabId: `agent_${parent.id}`,
|
parentTabId: `agent_${parent.id}`,
|
||||||
|
placement: { mode: "prefer", paneId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useWorkspaceLayoutStore.getState();
|
const state = useWorkspaceLayoutStore.getState();
|
||||||
const layout = state.layoutByWorkspace[workspaceKey!];
|
const layout = state.layoutByWorkspace[workspaceKey!];
|
||||||
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey!];
|
const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey!];
|
||||||
|
|
||||||
expect(explorerPaneId).toBeTruthy();
|
expect(sidePanelPaneId).toBeTruthy();
|
||||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
|
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId);
|
||||||
expect(findPaneById(layout.root, explorerPaneId!)?.hidden).toBeUndefined();
|
expect(findPaneById(layout.root, sidePanelPaneId!)?.hidden).toBeUndefined();
|
||||||
expect(layout.focusedPaneId).toBe(explorerPaneId);
|
expect(layout.focusedPaneId).toBe(sidePanelPaneId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens a provider subagent in the explorer pane when focused", () => {
|
it("opens a provider subagent in the side panel when focused", () => {
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
workspaceId: WORKSPACE_ID,
|
workspaceId: WORKSPACE_ID,
|
||||||
@@ -298,18 +302,22 @@ describe("workspace subagents integration", () => {
|
|||||||
expect(workspaceKey).toBeTruthy();
|
expect(workspaceKey).toBeTruthy();
|
||||||
|
|
||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
const tabId = store.openTabInExplorerPaneFocused(workspaceKey!, {
|
const paneId = store.showSidePanel(workspaceKey!) as string;
|
||||||
|
const tabId = store.openTab({
|
||||||
|
workspaceKey: workspaceKey!,
|
||||||
target: { kind: "provider_subagent", parentAgentId: "parent-agent", subagentId: "task-1" },
|
target: { kind: "provider_subagent", parentAgentId: "parent-agent", subagentId: "task-1" },
|
||||||
|
intent: "reveal",
|
||||||
parentTabId: "agent_parent-agent",
|
parentTabId: "agent_parent-agent",
|
||||||
|
placement: { mode: "prefer", paneId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useWorkspaceLayoutStore.getState();
|
const state = useWorkspaceLayoutStore.getState();
|
||||||
const layout = state.layoutByWorkspace[workspaceKey!];
|
const layout = state.layoutByWorkspace[workspaceKey!];
|
||||||
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey!];
|
const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey!];
|
||||||
|
|
||||||
expect(explorerPaneId).toBeTruthy();
|
expect(sidePanelPaneId).toBeTruthy();
|
||||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
|
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId);
|
||||||
expect(findPaneById(layout.root, explorerPaneId!)?.hidden).toBeUndefined();
|
expect(findPaneById(layout.root, sidePanelPaneId!)?.hidden).toBeUndefined();
|
||||||
expect(layout.focusedPaneId).toBe(explorerPaneId);
|
expect(layout.focusedPaneId).toBe(sidePanelPaneId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export interface PrepareWorkspaceTabInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PrepareWorkspaceTabDeps {
|
export interface PrepareWorkspaceTabDeps {
|
||||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
openTab: (input: {
|
||||||
|
workspaceKey: string;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
intent: "reveal";
|
||||||
|
}) => string | null;
|
||||||
pinAgent: (workspaceKey: string, agentId: string) => void;
|
pinAgent: (workspaceKey: string, agentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ export function prepareWorkspaceTab(
|
|||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
}) ?? "";
|
}) ?? "";
|
||||||
|
|
||||||
deps.openTabFocused(key, target);
|
deps.openTab({ workspaceKey: key, target, intent: "reveal" });
|
||||||
|
|
||||||
if (input.pin && target.kind === "agent") {
|
if (input.pin && target.kind === "agent") {
|
||||||
deps.pinAgent(key, target.agentId);
|
deps.pinAgent(key, target.agentId);
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ function createFakeLayout() {
|
|||||||
return {
|
return {
|
||||||
openedTabs,
|
openedTabs,
|
||||||
pinnedAgents,
|
pinnedAgents,
|
||||||
openTabFocused: (key: string, target: WorkspaceTabTarget) => {
|
openTab: ({
|
||||||
|
workspaceKey: key,
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
workspaceKey: string;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
}) => {
|
||||||
openedTabs.push({ key, target });
|
openedTabs.push({ key, target });
|
||||||
return target.kind === "agent" ? target.agentId : null;
|
return target.kind === "agent" ? target.agentId : null;
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user