Compare commits

...
9 Commits
Author SHA1 Message Date
Mohamed Boudra 6da9fa43fa chore(release): cut 0.6.0 2026-08-25 15:58:11 +02:00
Mohamed Boudra cd8c868b32 docs: add 0.6.0 changelog 2026-08-25 15:50:49 +02:00
Mohamed Boudra 6f6466d4cb Wait for authoritative provider readiness before prompting (#3821)
* fix(server): trust provider connection lifecycle events

Readiness now waits for the upstream connection event, and later connection events directly trigger the existing gap repair. This removes the parallel synthetic reconnection contract while preserving transport ownership and prompt gating.

* test(server): model OpenCode global event envelopes
2026-08-25 15:28:06 +02:00
Mohamed Boudra 6147991519 test(app): follow filtered launcher order 2026-08-25 14:58:35 +02:00
Mohamed Boudra bbe3f17327 test(app): follow Explorer subagent placement 2026-08-25 13:50:54 +02:00
Mohamed Boudra c914bcb44b feat(app): make Explorer a first-class pane host (#3826) 2026-08-25 13:25:57 +02:00
Mohamed Boudra 54299a2e6c fix(server): let event-stream startup recover once (#3814)
The readiness wait expired at the same boundary as the producer watchdog, so a reconnect already initiated by the event consumer could never benefit the pending turn. Keep dispatch gated, allow that retry to land, and preserve the actual SDK failure details for logs and user-visible reports.
2026-08-24 19:31:11 +02:00
f4b5f32862 test(e2e): boot the Playwright harness on Windows (#3804)
* test(e2e): boot the Playwright harness on Windows

The harness could not start on Windows. Metro was launched with
spawn("npx", ...) and the isolated daemon resolved its runner with
execSync("which tsx") — npx is a .cmd shim that Node's bare spawn
cannot launch, and `which` does not exist there.

Route both through process.execPath instead of the node_modules/.bin
shims. Node refuses to spawn .cmd or .bat without shell: true, and
shell: true is the wrong escape hatch: it concatenates argv unescaped
(DEP0190), splits paths containing spaces, and sends kill signals to
cmd.exe rather than the real child. Spawning Node directly has none of
those properties and is identical on POSIX, so no platform branch is
needed. The same reasoning fixes the published-server npm install,
which passed "npm.cmd" to execFileSync and had been failing with
EINVAL on Windows since Node's CVE-2024-27980 mitigation landed.

Teardown now kills the process tree. A Windows signal reaches only the
direct child, so Metro's and the supervisor's forked workers kept the
listening port and the next run failed to bind.

The daemon startup deadline goes from 20s to 90s. A cold tsx start was
measured at 25s on Windows and exceeded 60s under load; the loop still
fails immediately on a crashed child, so the ceiling only costs time
that a genuinely slow start needs.

The fake editor and gh binaries the harness puts on PATH gain .cmd
wrappers, because Windows resolves PATH entries by PATHEXT and cannot
execute an extension-less shebang file. The wrappers point at the same
payload file so the recorded command name stays "cursor", "code", or
"gh" on every platform.

The asdf-backed local Elixir relay stays POSIX-only and now says so
before spawning.

* test(ci): follow shared e2e daemon launcher

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-24 18:20:19 +02:00
paseo-ai[bot] 7db654aa0a fix: update lockfile signatures and Nix hash [skip ci] 2026-08-24 12:40:34 +00:00
221 changed files with 9328 additions and 4978 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 0.6.0 - 2026-08-25
### Changed
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
### Added
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
### Fixed
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
## 0.5.2 - 2026-08-24
### Fixed
+1 -1
View File
@@ -52,7 +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/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/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
| [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/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
+6
View File
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
+13 -2
View File
@@ -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
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app
```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
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
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
boundaries are nested. A printable key after an empty newline should not change either height.
### Preview Windows and Linux window controls on macOS
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
```bash
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
```
The override is rejected in packaged builds. Restart the desktop process when changing it.
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
+74
View File
@@ -0,0 +1,74 @@
# Explorer sidebar and side pane
The Explorer sidebar and the side pane share panel implementations, but they have different shell
contracts.
| Surface | Purpose | Lifecycle |
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
## Panel host contract
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
fixed-target labels and icons from that registration, filter by host, and never substitute one
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
a compatible pane.
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
never read or modify that width.
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
toggles the singleton Files and Changes views. Individual tab menus close instances or move
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
an Explorer tab does not change workspace focus.
Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined
full-screen Explorer overlay and close it after a file opens. Wide native layouts without pane
splits use the same combined content in a resizable inline dock; opening a file leaves that dock
visible. Both presentations keep their selection in the panel store and reuse the layout store's
per-workspace Explorer width. They do not create a second Explorer lifecycle.
## Side pane
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
right split around the workspace root; later side opens reuse it.
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
later side open creates a new pane. There is no hidden side-pane lifecycle.
Placement intent still controls existing tabs:
| Mode | New target | Existing target |
| --------- | --------------------------- | --------------------------------- |
| `pane` | opens in the requested pane | moves to the requested pane |
| `prefer` | opens in the requested pane | stays where the user placed it |
| `focused` | opens in the focused pane | focuses it where it already lives |
| `ambient` | opens in a compatible pane | focuses it where it already lives |
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
new target and never yanks an existing tab out of a user-selected pane.
## Routing preferences
Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files,
Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches
default off. Mobile ignores them.
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
specific shell inside a panel.
+2 -1
View File
@@ -19,7 +19,8 @@ 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.
- **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.
- **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.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **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 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`.
+4
View File
@@ -165,6 +165,10 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
when moved between hosts. Explorer configuration can create workspace-context panels and remove
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
-98
View File
@@ -1,98 +0,0 @@
# 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 or a failed workspace
setup. 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. A failed workspace setup adds a background tab to the main pane.
Running and successful setup never seed tabs.
## 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 Changes keyboard shortcut and the diff-stat pill above an agent composer are
supporting opens: they follow the preference and focus an existing Changes tab wherever
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
hides the panel without closing the tab. A different visible Side panel tab switches to
Changes instead.
## 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.
+3
View File
@@ -189,6 +189,9 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
## Pull-request test routing
+1 -1
View File
@@ -1 +1 @@
sha256-o8d/nX0FdM6QB7c/RWpo1USsz8UNmwohSqkS7tflayc=
sha256-TRZej2L43C3go4NWe496Dqs/4A+0GivCRtGzt3pX2dw=
+25 -25
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.2",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.2",
"version": "0.6.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36140,7 +36140,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.2",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/server": "0.5.2",
"@getpaseo/client": "0.6.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/server": "0.6.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"@getpaseo/protocol": "0.5.2",
"@getpaseo/relay": "0.5.2",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37458,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.2",
"version": "0.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.2",
"version": "0.6.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.2",
"@getpaseo/client": "0.6.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.2",
"@getpaseo/client": "0.6.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,17 +38878,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.2",
"@getpaseo/highlight": "0.5.2",
"@getpaseo/plugin": "0.5.2",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/relay": "0.5.2",
"@getpaseo/client": "0.6.0",
"@getpaseo/highlight": "0.6.0",
"@getpaseo/plugin": "0.6.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.6.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39940,7 +39940,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.2",
"version": "0.6.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.2",
"version": "0.6.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
.join("\n");
}
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
const viewportTop = frame?.userRow.rect?.top;
const scrollTop = frame?.scroll.scrollTop;
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
}
function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top;
const baselineFrame = frames[first];
const baselineContentTop = submittedRowContentTop(baselineFrame);
const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
);
const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
);
const shifted = frames.findIndex(
(frame, index) =>
const contentShifted = frames.findIndex((frame, index) => {
const contentTop = submittedRowContentTop(frame);
return (
index > first &&
hasPaintedLayout(frame.userRow) &&
baseline !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
baselineContentTop !== undefined &&
contentTop !== undefined &&
Math.abs(contentTop - baselineContentTop) > 1
);
});
const viewportChanged = frames.findIndex(
(frame, index) =>
index > first &&
[
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
].some(Boolean),
);
const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
: []),
...(shifted >= 0
...(contentShifted >= 0
? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
]
: []),
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
];
let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted;
if (viewportChanged >= 0) failure = viewportChanged;
if (contentShifted >= 0) failure = contentShifted;
if (disappeared >= 0) failure = disappeared;
expect(
violations,
+83 -207
View File
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
function changesTree(page: Page) {
return page.getByTestId("changes-tree-rail-tree");
}
function changesContent(page: Page) {
return page.getByTestId("changes-tree-rail-content");
return page.getByTestId("changes-file-tree").filter({ visible: true });
}
async function readFileIfPresent(filePath: string): Promise<string | null> {
@@ -264,7 +260,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
});
@@ -272,19 +269,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger");
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("Uncommitted");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(mode).toContainText("Committed");
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
await expect(mode).toContainText("Committed");
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -297,7 +294,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger");
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
@@ -305,8 +303,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
await expect(mode).toContainText("Committed");
@@ -321,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
const deletedFileName = page.getByTestId("diff-file-1-name");
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
@@ -432,7 +430,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
.toBe(AFTER);
await page.getByTestId("changes-toggle-tree").click();
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click();
await expect
@@ -447,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
});
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
const modifiedRow = tree.getByTestId("diff-tree-file-0");
const deletedRow = tree.getByTestId("diff-tree-file-1");
@@ -471,7 +466,6 @@ test("changes context menu recursively collapses descendant folders", async ({ p
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
@@ -516,7 +510,6 @@ test("changes context menus expose folder revert and restore a file after confir
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert");
@@ -649,6 +642,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
@@ -658,34 +656,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
await diffOptions.click();
// A workspace tab has nowhere to open itself, so the menu withholds that item.
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
await page.getByTestId("changes-toggle-collapse-all").click();
await diffOptions.click();
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
await page.getByTestId("changes-toggle-collapse-all").click();
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
await collapseFiles.click();
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
await collapseFiles.click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const layoutItem = page.getByTestId("changes-toggle-layout");
await diffOptions.click();
await expect(layoutItem).toContainText("Side-by-side diff");
// Leading icon only: the layout is still unified, so no trailing check.
await expect(layoutItem.locator("svg")).toHaveCount(1);
await layoutItem.click();
await diffOptions.click();
await expect(layoutItem.locator("svg")).toHaveCount(2);
const layout = visiblePanel.getByTestId("changes-toggle-layout");
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
await layout.click();
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
await page.getByText("Wrap long lines", { exact: true }).click();
await diffOptions.click();
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
"aria-label",
"Refresh",
);
await wrapLines.click();
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -701,151 +695,23 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
});
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
page,
}, testInfo) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-layout-unified")).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.
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-layout")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
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 content = changesContent(page);
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
"user-select",
"none",
);
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await tree
.getByTestId("diff-folder-src-toggle")
.locator("svg")
.boundingBox();
expect(folderToggleBounds).not.toBeNull();
expect(folderChevronBounds).not.toBeNull();
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
folderToggleBounds!.y + folderToggleBounds!.height / 2,
0,
);
const folderLabelBounds = await tree
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
.boundingBox();
const fileLabelBounds = await tree
.getByTestId("diff-tree-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(12, 0);
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
const fileToggle = tree.getByTestId("diff-tree-file-0-toggle");
await fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-tree-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
await fileToggle.click();
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
const contentBounds = await content.boundingBox();
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
expect(contentBounds).not.toBeNull();
expect(focusedFileBounds).not.toBeNull();
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
await tree.getByTestId("diff-folder-src-toggle").click();
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-tree").click();
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
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 actions compact and menu-only", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
if (!(await compactChangesTab.isVisible())) {
await page.getByTestId("workspace-explorer-toggle").first().click();
}
await expect(compactChangesTab).toBeVisible();
await compactChangesTab.click();
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
const options = compactExplorer.getByRole("button", { name: "Diff options" });
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
actions.boundingBox(),
options.boundingBox(),
@@ -873,7 +739,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
await expect(wrapLines).toBeVisible();
await wrapLines.click();
await options.click();
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
await expect(
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
).toContainText("Scroll long lines");
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
@@ -883,7 +751,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
const canvas = page.getByTestId("git-diff-canvas");
const root = page.getByTestId("git-diff-canvas-root");
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
await expect(handle).toBeVisible();
await expect
.poll(async () => {
@@ -904,11 +774,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
};
}),
]);
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
await page.mouse.down();
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const duringDrag = await Promise.all([
@@ -1074,6 +944,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await expect(grab).toHaveCSS("cursor", "grabbing");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
await page.mouse.up();
@@ -1313,24 +1184,20 @@ 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";
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
const currentLayout =
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
? "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;
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
if (currentWrapLines !== requestedPresentation.wrapLines) {
await wrapItem.click();
} else {
await page.keyboard.press("Escape");
}
}
@@ -1365,12 +1232,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
});
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
await expect
@@ -1491,6 +1352,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
@@ -1507,13 +1369,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
timeout: 30_000,
});
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await openChangesPanel(page);
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await changesTab.click();
const changedFile = explorer
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
@@ -1,21 +1,12 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
function mainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function sidePanel(page: Page) {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
@@ -24,34 +15,12 @@ test("Changes shortcut follows the default Side panel placement", async ({ page
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel(page)).toHaveCount(0);
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const COMMIT_SUBJECT = "Show commit timestamps";
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await openChangesPanel(page);
await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesPanel(page);
await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
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(layoutToggleBox.width).toBe(20);
expect(layoutToggleBox.height).toBe(20);
expect(layoutToggleGlyphBox.width).toBe(14);
expect(layoutToggleGlyphBox.height).toBe(14);
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
@@ -38,7 +38,10 @@ async function seedChangedAgent(repoPrefix: string) {
}
}
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
test("composer diff stat opens Changes in the configured side pane", async ({ page }) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { changesLinks: true } }));
}, APP_SETTINGS_KEY);
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
@@ -54,31 +57,24 @@ test("composer diff stat toggles Changes in the preferred Side panel", async ({
await expect(pill).toContainText("-0");
await pill.click();
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
const sidePane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ visible: true })
.filter({ has: page.getByTestId("working-diff-panel") });
await expect(sidePane.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(visibleMainPane(page).getByTestId("working-diff-panel")).toHaveCount(0);
await test.step("switching from another Side panel view reveals Changes", async () => {
await test.step("Explorer navigation does not replace the side pane", async () => {
await openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
await pill.click();
await expect(sidePanel).toHaveCount(0);
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
await test.step("clicking again restores Changes", async () => {
await pill.click();
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
}
@@ -96,19 +92,16 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
await page.getByTestId("composer-diff-stat-pill").click();
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try {
@@ -125,7 +118,9 @@ test("composer diff stat opens Changes in the focused pane when Side panel routi
timeout: 30_000,
});
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
await expect(
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
).toHaveCount(1);
} finally {
await workspace.cleanup();
}
@@ -1,7 +1,8 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test("opens a terminal in the focused pane", async ({ page }) => {
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
const focusedPane = page
.getByTestId("workspace-tabs-row")
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
.first();
const primaryPane = page
.getByTestId("workspace-tabs-row")
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
.first();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
const originalPaneChild = page
.getByTestId("split-group-child")
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
await expect(focusedPaneChild).toBeVisible();
const focusedPaneId = await focusedPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
const originalPaneId = await originalPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
expect(focusedPaneId).not.toBeNull();
expect(originalPaneId).not.toBeNull();
const focusedPane = page.getByTestId(focusedPaneId!);
const originalPane = page.getByTestId(originalPaneId!);
await pressDirectNewTabShortcut(page, "t");
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
timeout: 30_000,
});
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
} finally {
await workspace.cleanup();
}
@@ -13,38 +13,25 @@ async function expectLauncherSelection(launcher: Locator, name: string): Promise
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-new-tab-panel");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
test.describe("New tab keyboard launcher", () => {
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const launcher = await openSidePanelWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(
page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-tab-files"),
).toBeVisible({ timeout: 30_000 });
const explorer = await openExplorerWithKeyboard(page);
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
} finally {
await workspace.cleanup();
}
@@ -65,14 +52,12 @@ test.describe("New tab keyboard launcher", () => {
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await expectLauncherSelection(launcher, "Diff");
await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
@@ -8,7 +8,10 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
@@ -22,23 +25,18 @@ function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
}
/** The explorer pane is the only tab row carrying the Changes tab. */
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
has: page.getByTestId("workspace-tab-working_diff"),
});
return visible(page, "explorer-sidebar-tab-rail");
}
function mainTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
hasNot: page.getByTestId("workspace-tab-working_diff"),
});
return visible(page, "workspace-tabs-row");
}
async function focusExplorerPane(page: Page): Promise<void> {
await openChangesPanel(page);
const changesTab = visible(page, "workspace-tab-working_diff").first();
await expect(changesTab).toHaveAttribute("aria-selected", "true");
async function selectExplorerChanges(page: Page): Promise<void> {
await openChangesTreePanel(page);
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
}
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
@@ -115,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("focus the explorer pane", async () => {
await focusExplorerPane(page);
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
@@ -140,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not steal the explorer pane's focus.
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
"aria-selected",
"true",
);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
@@ -170,8 +165,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
@@ -0,0 +1,48 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
ensureExplorerSidebar,
openFilesPanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
test.describe("Explorer sidebar", () => {
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-sidebar-defaults-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const mainTabsBefore = await page
.getByTestId("workspace-pane-main")
.locator('[data-testid^="workspace-tab-"]')
.count();
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
await openFilesPanel(page);
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(explorerSidebar(page)).toHaveCount(0);
await expect(
page.getByTestId("workspace-pane-main").locator('[data-testid^="workspace-tab-"]'),
).toHaveCount(mainTabsBefore);
} finally {
await workspace.cleanup();
}
});
});
@@ -19,14 +19,8 @@ function readPersistedPanelState(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem("panel-state"));
}
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
function dockedSidebar(page: Page): Locator {
return visible(page, "explorer-header");
}
/** The desktop companion surface is a hideable side panel. */
function sidePanel(page: Page): Locator {
return visible(page, "workspace-side-panel");
function explorerSidebar(page: Page): Locator {
return visible(page, "workspace-explorer-sidebar");
}
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await test.step("no docked sidebar, on load or after toggling", async () => {
await expect(dockedSidebar(page)).toHaveCount(0);
await test.step("the retired flag does not reveal Explorer", async () => {
await expect(explorerSidebar(page)).toHaveCount(0);
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
).toBeVisible();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
});
await test.step("the retired keys are migrated out of storage", async () => {
@@ -107,22 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
await test.step("the shortcut collapses both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
await expect(agentList).toHaveCount(0);
});
await test.step("the shortcut restores both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
});
} finally {
await workspace.cleanup();
@@ -9,6 +9,8 @@ import {
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
@@ -218,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
});
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(
settingsKey,
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
);
}, APP_SETTINGS_KEY);
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
target,
@@ -1,142 +0,0 @@
// 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;
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
return locator.evaluate((element) => {
let current: Element | null = element;
while (current) {
const color = getComputedStyle(current).backgroundColor;
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
current = current.parentElement;
}
return "transparent";
});
}
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 Files panel starts with an empty content pane beside the tree", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
await expect(rail).toBeVisible({ timeout: 30_000 });
const content = rail.getByTestId("files-tree-rail-content");
const tree = rail.getByTestId("files-tree-rail-tree");
await expect(content).toHaveText("Choose a file");
await expect(tree).toContainText("docs");
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
await effectiveBackground(content),
);
});
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");
});
});
@@ -1,60 +0,0 @@
import { test, expect } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectExplorerNodeRetained,
hideExplorerSplit,
measureExplorerSplit,
openExplorerSplit,
resizeExplorerSplit,
stampExplorerNode,
} from "../support/helpers/split-pane";
import {
createAgentTabFromMenu,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
test.describe("Hidden split pane retention", () => {
test("releases all layout space without unmounting and restores its dragged width", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await createAgentTabFromMenu(page);
await openExplorerSplit(page);
// Drag to a non-default width first: restoring the default would prove nothing.
await resizeExplorerSplit(page, 137);
await stampExplorerNode(page);
const open = await measureExplorerSplit(page);
expect(open.explorerWidth).toBeGreaterThan(250);
expect(open.separatorCount).toBe(1);
await hideExplorerSplit(page);
const hidden = await measureExplorerSplit(page);
expect(hidden.explorerWidth).toBe(0);
// The sibling must absorb the whole row, not just collapse the hidden pane.
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
expect(hidden.separatorCount).toBe(0);
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
await expectExplorerNodeRetained(page);
await openExplorerSplit(page);
await expectExplorerNodeRetained(page);
const reopened = await measureExplorerSplit(page);
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
expect(reopened.separatorCount).toBe(1);
await testInfo.attach("split-pane-geometry", {
body: JSON.stringify({ open, hidden, reopened }, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
});
});
@@ -11,6 +11,7 @@ import {
countTabsOfKind,
waitForTabBar,
pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
@@ -177,13 +178,11 @@ test.describe("Tab creation", () => {
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const changes = panel.getByRole("button", { name: /Changes/ });
const files = panel.getByRole("button", { name: /Files/ });
const diff = panel.getByRole("button", { name: /Diff/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(agent).toBeFocused();
await page.keyboard.press("ArrowDown");
@@ -191,6 +190,12 @@ test.describe("Tab creation", () => {
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await pressDirectNewTabShortcut(page, "e");
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
).toBeVisible();
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused();
-238
View File
@@ -1,238 +0,0 @@
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 newTabPanel(scope: Locator): Locator {
return scope.getByTestId("workspace-new-tab-panel");
}
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
return launcher
.getByRole("button")
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
/** 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 });
const launcher = newTabPanel(sidePanel(page));
await expect(launcher).toBeVisible();
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
"Changes",
"Files",
"Terminal",
"Agent",
]);
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 newTabPanel(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("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(newTabPanel(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 = newTabPanel(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
const newTabChip = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await newTabChip.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.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-new-tab-panel")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("closing New cannot remove the last visible 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 newTabPanel(sidePanel(page))
.getByRole("button", { name: "Files", exact: true })
.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 now contains New and is the only pane the user can see. Closing its
// tab cannot remove the final visible pane.
const lastPane = newTabPanel(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
const finalNewTab = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await finalNewTab.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
await expect(lastPane).toBeVisible();
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 newTabPanel(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-button").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();
}
});
});
@@ -29,10 +29,10 @@ test("sidebar and workspace pane borders highlight on hover", async ({ page, wit
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
await page.getByTestId("workspace-explorer-toggle").first().click();
const splitHandle = page.getByTestId("workspace-split-resize-handle");
const splitHandle = page.getByTestId("workspace-explorer-sidebar-resize-handle");
await expectBorderHighlight(
page,
"workspace-split-resize-handle",
"workspace-explorer-sidebar-resize-handle",
splitHandle.getByRole("separator"),
"3px",
);
@@ -319,7 +319,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(closedIcon).toBeVisible();
const closedBounds = await closedIcon.boundingBox();
expect(closedBounds).not.toBeNull();
expect(closedBounds?.x).toBeCloseTo(12, 0);
expect(closedBounds?.x).toBeCloseTo(9, 0);
expect(closedBounds?.y).toBe(openBounds?.y);
});
@@ -342,16 +342,19 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
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("explorer-sidebar-tab-rail")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
await explorerToggle.click();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toHaveCount(0);
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import type { ChildProcess } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -23,6 +22,7 @@ import {
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const startedAt = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - startedAt < 20_000) {
if (child.exitCode !== null) {
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
while (Date.now() - startedAt < 90_000) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
);
}
try {
await new Promise<void>((resolve, reject) => {
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
async function startRestartDaemon(input: {
paseoHome: string;
origin: string;
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
}
const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
try {
await waitForServer(port, child);
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
return {
port,
close: () => stopProcess(child),
close: () => killProcessTree(child),
};
}
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]);
}
async function expectWorkspaceRowHasOnlyIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowHasOnlyIndicator(page, {
serverId,
workspaceId: seeded.workspaceA,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
+6 -23
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs";
import path from "node:path";
import net from "node:net";
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
}
}
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
once(child, "exit"),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
resolve();
}, 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
const expoCli = require.resolve("expo/bin/cli");
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
cwd: appDir,
env: {
...process.env,
@@ -216,11 +199,11 @@ export default async function globalSetup() {
console.log(`[e2e] Metro warmed on port ${metroPort}`);
return async () => {
await stopProcess(metroProcess);
await killProcessTree(metroProcess);
console.log("[e2e] Metro stopped");
};
} catch (error) {
await stopProcess(metroProcess);
await killProcessTree(metroProcess);
throw error;
}
}
@@ -1,27 +1,27 @@
import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex";
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
import { openChangesTreePanel } from "./workspace-tabs";
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real
// checkout directory from the opaque workspace id. Scoping to the changes header
// keeps the matcher unambiguous even when the header is shared with diff actions.
// checkout directory from the opaque workspace id. Scoping to the repository
// header keeps the matcher aligned with the row that owns repository identity.
function branchSwitcherTrigger(page: Page, branchName: string) {
return page
.getByTestId("changes-header")
.getByTestId("changes-repository-header")
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
.filter({ visible: true })
.first();
}
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
// Opens the fixed Explorer Changes view, where the switcher lives.
export async function openChangesPanel(page: Page): Promise<void> {
await openWorkspaceChangesPanel(page);
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
await openChangesTreePanel(page);
await expect(
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
@@ -1,6 +1,6 @@
import { fork, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface OutdatedDaemon {
endpoint: string;
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
endpoint: ready.endpoint,
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
serverId: ready.serverId,
async close() {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
await once(child, "exit");
},
close: () => killProcessTree(child),
};
} catch (error) {
child.kill("SIGTERM");
await killProcessTree(child);
throw error;
}
}
+17 -3
View File
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
let realGhPath = "";
try {
realGhPath = execSync("which gh").toString().trim();
const locator = process.platform === "win32" ? "where.exe" : "which";
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
.split(/\r?\n/u)
.map((candidate) => candidate.trim())
.filter(Boolean);
realGhPath =
candidates.find(
(candidate) =>
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
) ?? "";
} catch {
// The local GitHub fixture remains usable without a system gh binary.
}
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
@@ -41,6 +49,9 @@ if (recordPath) {
const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
}
}
const fakeGhPath = path.join(binDir, "gh");
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
`;
await writeFile(fakeGhPath, fakeGhSource);
await chmod(fakeGhPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
}
return binDir;
}
@@ -1,16 +1,10 @@
import { once } from "node:events";
import {
spawn,
execFileSync,
execSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
export interface IsolatedHostDaemon {
serverId: string;
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 20_000;
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
);
}
try {
await new Promise<void>((resolve, reject) => {
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
path.join(publishedPackageRoot, "package.json"),
`${JSON.stringify({ private: true })}\n`,
);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
try {
const npmCli = process.env.npm_execpath;
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
throw new Error(
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
);
}
execFileSync(
npmCommand,
process.execPath,
[
npmCli,
"install",
"--no-audit",
"--no-fund",
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
{ cwd: publishedPackageRoot, stdio: "ignore" },
);
} catch (error) {
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
await rm(publishedPackageRoot, { recursive: true, force: true });
throw error;
}
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
};
const child = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
await waitForServer(port, child);
return child;
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
await killProcessTree(child);
child = await spawnDaemon();
},
close: async () => {
if (closed) return;
closed = true;
await stopProcess(child);
await killProcessTree(child);
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
@@ -1,8 +1,8 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface LocalElixirRelay {
endpoint: string;
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
});
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(force);
}
}
async function waitUntilReady(
port: number,
child: ChildProcess,
@@ -48,9 +35,9 @@ async function waitUntilReady(
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
);
}
try {
@@ -70,6 +57,12 @@ async function waitUntilReady(
}
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
if (process.platform === "win32") {
throw new Error(
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
);
}
const relayRoot =
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
try {
await waitUntilReady(port, child, () => output.join("\n"));
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw error;
}
};
const stop = async () => {
if (!child) return;
await stopProcess(child);
await killProcessTree(child);
child = null;
};
@@ -0,0 +1,84 @@
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { once } from "node:events";
/**
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
* of the `node_modules/.bin/tsx` shim.
*
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
* real child keeps the port. Spawning `process.execPath` has none of those
* properties and is byte-identical on POSIX, so there is no platform branch.
*/
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
if (entrypoint.startsWith("-")) {
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
}
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
}
/**
* Terminates a child and everything it forked, then waits for the exit.
*
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
* grandchildren holding the listening port and the next run fails to bind.
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
*/
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
if (!child || hasExited(child)) return;
const exited = once(child, "exit").then(() => undefined);
if (process.platform === "win32") {
const pid = child.pid;
if (pid === undefined) {
throw new Error("Cannot terminate a Windows process tree without a PID");
}
// Register the exit listener before taskkill to avoid missing a fast exit.
// Bound taskkill itself so a stuck system utility cannot hang teardown.
const completed = Promise.withResolvers<Error | null>();
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
completed.resolve(error),
);
const taskkillError = await completed.promise;
if (taskkillError && !hasExited(child)) {
try {
child.kill("SIGKILL");
} catch {
// The child can exit between the state check and the direct kill.
}
await waitForExitOrTimeout(exited, 5_000);
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
}
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
}
return;
}
child.kill("SIGTERM");
if (await waitForExitOrTimeout(exited, 5_000)) return;
child.kill("SIGKILL");
if (!(await waitForExitOrTimeout(exited, 5_000))) {
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
}
}
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
const timedOut = Promise.withResolvers<boolean>();
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
try {
return await Promise.race([exited.then(() => true), timedOut.promise]);
} finally {
clearTimeout(timeout);
}
}
@@ -1,84 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
export interface SplitPaneGeometry {
containerWidth: number;
explorerWidth: number;
visibleWidth: number;
separatorCount: number;
occupiedWidth: number;
}
function splitSeparator(page: Page): Locator {
return page.getByRole("separator", { name: "" });
}
export async function openExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
}
export async function hideExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
const separator = splitSeparator(page);
await expect(separator).toHaveCount(1);
const box = await separator.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Split separator has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
await page.mouse.up();
}
export async function stampExplorerNode(page: Page): Promise<void> {
await page
.getByTestId("split-group-child")
.last()
.evaluate((node) => {
Reflect.set(window, "__splitPaneExplorerNode", node);
node.setAttribute("data-split-pane-identity", "retained");
});
}
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
await expect(stampedNode).toHaveCount(1);
expect(
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
).toBe(true);
}
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
await expect(explorerChild).toHaveCount(1);
return explorerChild.evaluate((explorerNode) => {
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
.filter(
(candidate) =>
candidate !== explorerNode &&
(candidate as HTMLElement).dataset.testid === "split-group-child",
)
.sort(
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
)[0];
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
const visible = visibleNode.getBoundingClientRect();
const explorer = explorerNode.getBoundingClientRect();
const container = explorerNode.parentElement?.getBoundingClientRect();
if (!container) throw new Error("Split child has no container");
const separatorCount =
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
return {
containerWidth: container.width,
explorerWidth: explorer.width,
visibleWidth: visible.width,
separatorCount,
occupiedWidth: explorer.width + visible.width,
};
});
}
@@ -42,8 +42,8 @@ export async function expectFailedSetupTabSeededInMainPane(
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
const panel = await ensureSidePanel(page);
await expect(panel.getByTestId(tabId)).toHaveCount(0);
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
}
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
@@ -57,8 +57,8 @@ function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
function sidePanel(page: Page) {
return visibleTestId(page, "workspace-side-panel").first();
function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-explorer-sidebar").first();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
@@ -70,53 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
await expect(tab).toHaveAttribute("aria-selected", "true");
}
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
export async function ensureSidePanel(page: Page): Promise<Locator> {
/** Reveal the Explorer sidebar without changing its selected view. */
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const panel = sidePanel(page);
if ((await panel.count()) === 0) {
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
await toggle.click();
}
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
/**
* 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(
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
async function openExplorerView(
page: Page,
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
view: { tabTestId: 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-button").click();
await page
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
.filter({ visible: true })
.click();
}
await selectWorkspaceTab(tab);
const explorer = await ensureExplorerSidebar(page);
const tab = explorer.getByTestId(view.tabTestId);
await tab.click();
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000,
});
}
export async function openChangesTreePanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-changes_tree",
contentTestId: "changes-tree-panel",
});
}
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
launchItemId: "changes",
contentTestId: "working-diff-panel",
await openChangesTreePanel(page);
const changedFile = page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
});
}
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
launchItemId: "files",
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -128,12 +129,10 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
return;
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
launchItemId: "pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await trigger.click();
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.2",
"version": "0.6.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -30,6 +30,7 @@
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
@@ -0,0 +1,388 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
const APP_SETTINGS_KEY = "@paseo:app-settings";
export interface ProfileTarget {
serverId: string;
workspaceId: string;
}
export interface RenderSummary {
id: string;
renders: number;
mounts: number;
updates: number;
actualDurationMs: number;
maximumActualDurationMs: number;
}
export interface InteractionMeasurement {
name: string;
latencyMs: number;
commits: number;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: string[];
removedTestIds: string[];
trackedNodes: Array<{
selector: string;
existedBefore: boolean;
connected: boolean;
sameNode: boolean;
}>;
renders: RenderSummary[];
reasons: Record<string, Record<string, number>>;
heapAfterGcBytes?: number;
}
interface BrowserMeasurementState {
startedAt: number;
selectors: string[];
originals: Array<Element | null>;
observer?: MutationObserver;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: Set<string>;
removedTestIds: Set<string>;
}
declare global {
interface Window {
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
}
}
function parseCliJson<T>(output: string): T {
const arrayStart = output.indexOf("[\n");
const objectStart = output.indexOf("{\n");
const start = arrayStart >= 0 ? arrayStart : objectStart;
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
return JSON.parse(output.slice(start)) as T;
}
export function resolveProfileTarget(): ProfileTarget {
const repoRoot = resolve(process.cwd(), "../..");
const serverId = (
process.env.PASEO_PROFILE_SERVER_ID ??
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
).trim();
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, FORCE_COLOR: "0" },
});
const workspaces =
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
const workspace = workspaces.find(
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
);
if (!workspace) {
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
}
return { serverId, workspaceId: workspace.workspaceId };
}
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
await page.addInitScript(() => {
const preserveRenderProfile = (original: History["pushState"]) =>
function preserveProfileParam(
this: History,
state: unknown,
unused: string,
url?: string | URL | null,
) {
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
const nextUrl = new URL(String(url), location.href);
nextUrl.searchParams.set("renderProfile", "1");
return Reflect.apply(original, this, [
state,
unused,
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
]);
};
history.pushState = preserveRenderProfile(history.pushState);
history.replaceState = preserveRenderProfile(history.replaceState);
});
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
await workspaceRow.click();
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
}
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
await page.evaluate((settingsKey) => {
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
string,
unknown
>;
const currentPreferences =
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
? (persisted.openInSidePane as Record<string, unknown>)
: {};
localStorage.setItem(
settingsKey,
JSON.stringify({
...persisted,
openInSidePane: { ...currentPreferences, explorerChanges: true },
}),
);
}, APP_SETTINGS_KEY);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
}
export async function openChangesExplorer(page: Page): Promise<void> {
const explorer = page.getByTestId("workspace-explorer-sidebar");
if ((await explorer.count()) === 0) {
await page.keyboard.press("Meta+e");
}
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await expect(changesTab).toBeVisible();
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
await changesTab.click();
}
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
}
export function changedFileRows(page: Page): Locator {
return page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true });
}
export async function selectChangedFile(page: Page, index: number): Promise<void> {
const row = changedFileRows(page).nth(index);
await expect(row).toBeVisible();
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
}
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
}
export async function closeWorkingDiffPane(page: Page): Promise<void> {
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
await expect(tab).toBeVisible();
await tab.hover();
await page
.locator('[data-testid^="workspace-working-diff-close-"]')
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
}
export async function measureHeapAfterGc(page: Page): Promise<number> {
await page.requestGC();
return page.evaluate(() => {
const memory = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return memory.memory?.usedJSHeapSize ?? 0;
});
}
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
let previousCount = -1;
let unchangedSince = Date.now();
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
if (count !== previousCount) {
previousCount = count;
unchangedSince = Date.now();
} else if (Date.now() - unchangedSince >= quietMs) {
return;
}
await page.waitForTimeout(25);
}
throw new Error("React profiler did not become idle");
}
function summarizeRenders(
samples: Array<{
id: string;
phase: "mount" | "update" | "nested-update";
actualDuration: number;
}>,
): RenderSummary[] {
const summaries = new Map<string, RenderSummary>();
for (const sample of samples) {
const summary = summaries.get(sample.id) ?? {
id: sample.id,
renders: 0,
mounts: 0,
updates: 0,
actualDurationMs: 0,
maximumActualDurationMs: 0,
};
summary.renders += 1;
summary.mounts += sample.phase === "mount" ? 1 : 0;
summary.updates += sample.phase === "mount" ? 0 : 1;
summary.actualDurationMs += sample.actualDuration;
summary.maximumActualDurationMs = Math.max(
summary.maximumActualDurationMs,
sample.actualDuration,
);
summaries.set(sample.id, summary);
}
for (const summary of summaries.values()) {
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
}
return [...summaries.values()].sort(
(left, right) => right.actualDurationMs - left.actualDurationMs,
);
}
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
await waitForReactIdle(page);
await page.evaluate((trackedSelectors) => {
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
const collectTestIds = (node: Node, output: Set<string>) => {
if (!(node instanceof Element)) return;
const ownTestId = node.getAttribute("data-testid");
if (ownTestId) output.add(ownTestId);
for (const element of node.querySelectorAll("[data-testid]")) {
const testId = element.getAttribute("data-testid");
if (testId) output.add(testId);
}
};
const state: BrowserMeasurementState = {
startedAt: performance.now(),
selectors: trackedSelectors,
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
domMutations: 0,
addedElements: 0,
removedElements: 0,
addedTestIds: new Set(),
removedTestIds: new Set(),
};
state.observer = new MutationObserver((records) => {
state.domMutations += records.length;
for (const record of records) {
for (const node of record.addedNodes) {
state.addedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.addedTestIds);
}
for (const node of record.removedNodes) {
state.removedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.removedTestIds);
}
}
});
state.observer.observe(document.body, { childList: true, subtree: true });
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
}, selectors);
}
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
await page.evaluate(
() =>
new Promise<void>((resolveFrame) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
}),
);
await waitForReactIdle(page);
const raw = await page.evaluate((measurementName) => {
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
if (!state) throw new Error("Side-pane performance measurement was not started");
state.observer?.disconnect();
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
return {
name: measurementName,
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
commits: new Set(samples.map((sample) => sample.commitTime)).size,
domMutations: state.domMutations,
addedElements: state.addedElements,
removedElements: state.removedElements,
addedTestIds: [...state.addedTestIds].sort(),
removedTestIds: [...state.removedTestIds].sort(),
trackedNodes: state.selectors.map((selector, index) => {
const original = state.originals[index] ?? null;
const current = document.querySelector(selector);
return {
selector,
existedBefore: original !== null,
connected: original?.isConnected ?? false,
sameNode: original !== null && original === current,
};
}),
samples,
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
};
}, name);
return {
...raw,
renders: summarizeRenders(raw.samples),
};
}
export async function measureInteraction(
page: Page,
input: {
name: string;
trackedSelectors: string[];
interact: () => Promise<void>;
expectComplete: () => Promise<void>;
},
): Promise<InteractionMeasurement> {
await beginMeasurement(page, input.trackedSelectors);
await input.interact();
await input.expectComplete();
return finishMeasurement(page, input.name);
}
export function expectTrackedNodePreserved(
measurement: InteractionMeasurement,
selector: string,
): void {
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
expect.soft(tracked).toEqual({
selector,
existedBefore: true,
connected: true,
sameNode: true,
});
}
export function expectProfilerDidNotMount(
measurement: InteractionMeasurement,
idPrefix: string,
): void {
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
expect.soft(summaries.length).toBeGreaterThan(0);
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
}
@@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test";
import {
changedFileRows,
closeWorkingDiffPane,
enableChangesOpenInSidePane,
expectProfilerDidNotMount,
expectTrackedNodePreserved,
expectWorkingDiffVisible,
measureInteraction,
measureHeapAfterGc,
openChangesExplorer,
openProfileWorkspace,
resolveProfileTarget,
selectChangedFile,
type InteractionMeasurement,
} from "./helpers/side-pane-performance";
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const target = resolveProfileTarget();
const reports: InteractionMeasurement[] = [];
await test.step("open the real main workspace with Changes routed beside it", async () => {
await openProfileWorkspace(page, target);
await enableChangesOpenInSidePane(page);
await openChangesExplorer(page);
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
});
await test.step("creating the side pane keeps the main pane mounted", async () => {
const report = await measureInteraction(page, {
name: "create-side-pane",
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, 0),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
});
await test.step("changing the selected diff file updates the existing diff in place", async () => {
const report = await measureInteraction(page, {
name: "change-diff-focus",
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
interact: () => selectChangedFile(page, 1),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expectTrackedNodePreserved(report, WORKING_DIFF);
expectTrackedNodePreserved(report, DIFF_CANVAS);
expectTrackedNodePreserved(report, DIFF_TAB);
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
});
await test.step("repeated cold reopenings measure retained workspace work", async () => {
for (let index = 0; index < 5; index += 1) {
await closeWorkingDiffPane(page);
const report = await measureInteraction(page, {
name: `reopen-side-pane-${index + 1}`,
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, index % 2),
expectComplete: () => expectWorkingDiffVisible(page),
});
report.heapAfterGcBytes = await measureHeapAfterGc(page);
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expect(
report.renders.some(
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
),
).toBe(true);
}
});
const artifact = {
appUrl: testInfo.project.use.baseURL,
target,
reports,
};
await testInfo.attach("side-pane-performance", {
body: JSON.stringify(artifact, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Side pane: ${JSON.stringify(
reports.map((report) => ({
name: report.name,
latencyMs: report.latencyMs,
commits: report.commits,
domMutations: report.domMutations,
addedElements: report.addedElements,
removedElements: report.removedElements,
heapAfterGcBytes: report.heapAfterGcBytes,
trackedNodes: report.trackedNodes,
renders: report.renders.slice(0, 8),
})),
)}`,
);
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./perf",
timeout: 120_000,
expect: { timeout: 30_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
...devices["Desktop Chrome"],
baseURL: process.env.PASEO_PROFILE_APP_URL ?? "http://127.0.0.1:8081",
viewport: { width: 1440, height: 900 },
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
});
+3 -3
View File
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
import {
AssistantMessage,
SpeakMessage,
@@ -457,7 +457,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
openSidePanelView({
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
@@ -474,7 +474,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "side");
handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
+16 -8
View File
@@ -36,6 +36,7 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
@@ -76,7 +77,7 @@ import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { updateDesktopWindowChrome } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
@@ -86,6 +87,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -473,6 +475,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
@@ -545,8 +551,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
{sidebarChrome}
</WindowChromeRegion>
) : null}
{isCompactLayout ? (
<CompactExplorerSidebarHost enabled={chromeEnabled}>
{usesCompactExplorerHost ? (
<CompactExplorerSidebarHost
enabled={chromeEnabled}
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
@@ -574,6 +583,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost />
{isCompactLayout ? sidebarChrome : null}
<DownloadToast />
@@ -665,18 +675,16 @@ function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (isLoading || isNative) return;
void updateDesktopWindowControls({
void updateDesktopWindowChrome({
backgroundColor: surface0,
foregroundColor: foreground,
trafficLightOffsetY: -5,
trafficLightOffsetY: -4,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [isLoading, surface0, foreground]);
}, [isLoading, surface0]);
return null;
}
@@ -176,7 +176,7 @@ describe("useFileLink", () => {
lineStart: undefined,
lineEnd: undefined,
},
disposition: "side",
disposition: "preferred",
},
]);
});
@@ -117,7 +117,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
});
const onPress = useStableEvent(() => {
open(stableSource, "side");
open(stableSource, "preferred");
});
const target = useMemo(() => {
@@ -61,9 +61,8 @@ function source(gitActions: GitActions): {
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel",
toggleExplorerSidebar: "Toggle side panel",
},
icons: {},
shortcuts: {},
@@ -40,9 +40,8 @@ export interface WorkspaceCommandCenterLabels {
moveTabUp: string;
moveTabDown: string;
closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string;
toggleSidePanel: string;
toggleExplorerSidebar: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -62,9 +61,8 @@ export interface WorkspaceCommandCenterIcons {
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -78,9 +76,8 @@ export interface WorkspaceCommandCenterShortcuts {
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -198,7 +195,9 @@ function buildPanelContributions(
}),
);
if (!source.capabilities.canSplitPanes) continue;
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
if (panel.target !== "pull-request") continue;
const placements = ["side-pane", "focused-pane"] as const;
for (const [placementIndex, placement] of placements.entries()) {
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`,
@@ -447,15 +446,6 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:maximize-toggle",
rank: 61,
title: source.labels.togglePaneMaximization,
keywords: ["pane", "maximize", "restore", "toggle"],
icon: source.icons.maximize,
shortcutKeys: source.shortcuts.togglePaneMaximization,
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
@@ -522,12 +512,12 @@ export function buildWorkspaceCommandCenterContributions(
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "side-panel:toggle",
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleSidePanel,
keywords: ["side", "panel", "toggle", "show", "hide"],
icon: source.icons.sidePanel,
shortcutKeys: source.shortcuts.toggleSidePanel,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
@@ -131,6 +131,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({
@@ -11,7 +11,6 @@ import {
GitCompareArrows,
GitPullRequest,
Globe,
Maximize2,
Move,
PanelRight,
Pencil,
@@ -64,14 +63,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight),
explorerSidebar: getCommandCenterIcon(PanelRight),
};
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel",
"side-pane": "shell.commandCenter.openInSidePane",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
@@ -152,9 +150,8 @@ export function useWorkspaceCommandCenterActions(): void {
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -31,9 +31,7 @@ export function resolveWorkspaceCommandCenterShortcuts({
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
toggleExplorerSidebar:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
};
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Text, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { GitBranch } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
@@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
interface BranchSwitcherProps {
currentBranchName: string | null;
@@ -23,9 +24,7 @@ interface BranchSwitcherProps {
const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({
currentBranchName,
@@ -56,14 +55,6 @@ export function BranchSwitcher({
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
const triggerStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
(Boolean(hovered) || pressed) && styles.triggerHovered,
],
[],
);
const branchLeadingSlot = useMemo(
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
[],
@@ -90,21 +81,16 @@ export function BranchSwitcher({
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
<ToolbarLabelSelectTrigger
testID={testID}
label={currentBranchName}
open={isOpen}
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", {
branchName: currentBranchName,
})}
>
<ThemedGitBranch size={12} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text>
@@ -136,25 +122,6 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
},
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
},
triggerHovered: {
backgroundColor: theme.colors.surface1,
},
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
tooltipText: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
@@ -1,9 +1,12 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar";
import {
CompactExplorerSidebar,
NativeExplorerSidebarDock,
} from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
@@ -12,6 +15,7 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
@@ -94,10 +98,16 @@ function useActiveCompactExplorerSidebarModel(
interface CompactExplorerSidebarHostProps {
children: ReactNode;
enabled: boolean;
presentation: "overlay" | "dock";
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
export function CompactExplorerSidebarHost({
children,
enabled,
presentation,
}: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const [containerWidth, setContainerWidth] = useState(0);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
@@ -127,14 +137,54 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
});
},
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent],
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const explorer =
enabled && model ? (
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
{presentation === "dock" ? (
<NativeExplorerSidebarDock
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
persistenceKey={model.persistenceKey}
containerWidth={containerWidth}
onOpenFile={handleOpenFile}
/>
) : (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
)}
</DiffDocumentWorkspaceCacheProvider>
) : null;
if (presentation === "dock") {
return (
<View style={styles.row} onLayout={handleContainerLayout}>
<View style={styles.fill}>{children}</View>
{explorer}
</View>
);
}
return (
<>
<CompactExplorerOpenGestureSurface
@@ -143,15 +193,7 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
{explorer}
</>
);
}
@@ -160,4 +202,8 @@ const styles = {
fill: {
flex: 1,
},
row: {
flex: 1,
flexDirection: "row",
},
} as const;
@@ -1,5 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -29,6 +32,13 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { PullRequestContent } from "@/panels/pull-request";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -139,6 +149,118 @@ export function CompactExplorerSidebar({
);
}
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
persistenceKey: string;
containerWidth: number;
}
export function NativeExplorerSidebarDock({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
persistenceKey,
containerWidth,
}: NativeExplorerSidebarDockProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const storedWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const visibleWidth = resolveExplorerSidebarWidth({
requestedWidth: storedWidth,
containerWidth,
});
const resizeWidth = useSharedValue(visibleWidth);
const startWidthRef = useRef(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
useEffect(() => {
resizeWidth.value = visibleWidth;
}, [resizeWidth, visibleWidth]);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
const commitWidth = useCallback(
(width: number) => resizeExplorerSidebar(persistenceKey, width),
[persistenceKey, resizeExplorerSidebar],
);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => scheduleOnRN(showResizeGrip))
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleWidth + event.translationX;
resizeWidth.value = visibleWidth;
})
.onUpdate((event) => {
resizeWidth.value = resolveExplorerSidebarWidth({
requestedWidth: startWidthRef.current - event.translationX,
containerWidth,
});
})
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
);
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const dockStyle = useMemo(
() => [
styles.nativeDock,
{
display: isOpen ? ("flex" as const) : ("none" as const),
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
backgroundColor: theme.colors.surfaceSidebar,
},
animatedWidthStyle,
],
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
);
const dockContentStyle = useMemo(
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
[theme.colors.border],
);
return (
<RetainedPanelActivity active={isOpen}>
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
<View style={dockContentStyle}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="native-explorer-sidebar-resize-handle"
/>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={showMobileAgent}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
</RetainedPanelActivity>
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
@@ -230,7 +352,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.sidePanel.changes")}
label={t("workspace.tabs.explorerSidebar.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -238,7 +360,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.sidePanel.files")}
label={t("workspace.tabs.explorerSidebar.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -268,7 +390,7 @@ function ExplorerSidebarContent({
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -335,7 +457,6 @@ function ChangedFilesPane({
);
return (
<ChangesSurface
host="explorer"
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
@@ -370,6 +491,18 @@ function FilesPane({
const PrTabContent = PullRequestContent;
const styles = StyleSheet.create((theme) => ({
nativeDock: {
position: "relative",
height: "100%",
minHeight: 0,
overflow: "hidden",
},
nativeDockContent: {
position: "relative",
flex: 1,
minHeight: 0,
borderLeftWidth: 1,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -17,15 +17,20 @@ import { isNative } from "@/constants/platform";
* On macOS, Electron handles edge resize natively.
*/
export const titlebarDragSurfaceStyle: React.CSSProperties = {
cursor: "default",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
const DRAG_OVERLAY_STYLE: React.CSSProperties = {
...titlebarDragSurfaceStyle,
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
const TOP_RESIZER_STYLE: React.CSSProperties = {
@@ -0,0 +1,208 @@
import { useCallback, useMemo } from "react";
import { Pressable, View } from "react-native";
import Svg, { Line, Rect } from "react-native-svg";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
closeDesktopWindow,
minimizeDesktopWindow,
toggleDesktopMaximize,
} from "@/desktop/electron/window";
import {
getDesktopWindowControlsPresentation,
type DesktopWindowControlsPresentation,
} from "@/desktop/window-chrome-presentation";
import { useCustomDesktopWindowControls } from "@/utils/desktop-window";
import type { Theme } from "@/styles/theme";
type WindowControlKind = "minimize" | "maximize" | "restore" | "close";
function WindowControlGlyph({
kind,
color,
size,
strokeWidth,
}: {
kind: WindowControlKind;
color: string;
size: number;
strokeWidth: number;
}) {
if (kind === "minimize") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Line x1={3} y1={8} x2={13} y2={8} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
if (kind === "restore") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Rect
x={5.5}
y={3.5}
width={7}
height={7}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
<Line x1={3.5} y1={5.5} x2={3.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
<Line x1={3} y1={12.5} x2={10.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
if (kind === "close") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Line x1={4} y1={4} x2={12} y2={12} stroke={color} strokeWidth={strokeWidth} />
<Line x1={12} y1={4} x2={4} y2={12} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Rect
x={3.5}
y={3.5}
width={9}
height={9}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
</Svg>
);
}
const ThemedWindowControlGlyph = withUnistyles(WindowControlGlyph);
const mutedGlyphMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const whiteGlyphMapping = () => ({ color: "#ffffff" });
function WindowControl({
kind,
label,
onPress,
presentation,
}: {
kind: WindowControlKind;
label: string;
onPress: () => void;
presentation: DesktopWindowControlsPresentation;
}) {
const glyph = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => {
const highlighted = hovered || pressed;
const closeHighlighted = kind === "close" && highlighted;
return (
<View
style={[
styles.controlSurface,
presentation.hoverShape === "circle"
? styles.circularControlSurface
: styles.fullControlSurface,
highlighted ? styles.controlHovered : null,
closeHighlighted ? styles.closeHovered : null,
]}
>
<ThemedWindowControlGlyph
kind={kind}
size={presentation.glyphSize}
strokeWidth={presentation.glyphStrokeWidth}
uniProps={closeHighlighted ? whiteGlyphMapping : mutedGlyphMapping}
/>
</View>
);
},
[kind, presentation],
);
const controlStyle = useMemo(
() => [styles.control, { width: presentation.controlWidth }],
[presentation.controlWidth],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={controlStyle}
>
{glyph}
</Pressable>
);
}
export function DesktopWindowControls() {
const { t } = useTranslation();
const { visible, mode, isMaximized } = useCustomDesktopWindowControls();
const minimize = useCallback(() => void minimizeDesktopWindow(), []);
const toggleMaximize = useCallback(() => void toggleDesktopMaximize(), []);
const close = useCallback(() => void closeDesktopWindow(), []);
if (!visible || !mode) return null;
const presentation = getDesktopWindowControlsPresentation(mode);
return (
<View
style={[styles.controls, { height: presentation.controlHeight }]}
testID="desktop-window-controls"
>
<WindowControl
kind="minimize"
label={t("desktop.windowControls.minimize")}
onPress={minimize}
presentation={presentation}
/>
<WindowControl
kind={isMaximized ? "restore" : "maximize"}
label={t(
isMaximized ? "desktop.windowControls.restore" : "desktop.windowControls.maximize",
)}
onPress={toggleMaximize}
presentation={presentation}
/>
<WindowControl
kind="close"
label={t("desktop.windowControls.close")}
onPress={close}
presentation={presentation}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
controls: {
position: "absolute",
top: 0,
right: 0,
zIndex: 3000,
flexDirection: "row",
},
control: {
height: "100%",
alignItems: "center",
justifyContent: "center",
},
controlSurface: {
alignItems: "center",
justifyContent: "center",
},
fullControlSurface: {
width: "100%",
height: "100%",
},
circularControlSurface: {
width: 24,
height: 24,
borderRadius: 12,
},
glyph: {
flexShrink: 0,
},
controlHovered: {
backgroundColor: theme.colors.interactionHighlight,
},
closeHovered: {
backgroundColor: "rgba(232, 17, 35, 0.9)",
},
}));
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
describe("Explorer sidebar layout", () => {
it("keeps the sidebar width fixed when the workspace body changes size", () => {
const narrow = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1200 });
const wide = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1520 });
expect(narrow[1] * 1200).toBeCloseTo(320);
expect(wide[1] * 1520).toBeCloseTo(320);
});
it("has no fixed maximum while preserving room for the workspace body", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
});
});
@@ -0,0 +1,27 @@
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
const MIN_WORKSPACE_BODY_WIDTH = 400;
export function resolveExplorerSidebarWidth(input: {
requestedWidth?: number;
containerWidth: number;
}): number {
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
const maximumVisibleWidth =
input.containerWidth > 0
? Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, input.containerWidth - MIN_WORKSPACE_BODY_WIDTH)
: requestedWidth;
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
}
export function resolveExplorerSidebarDockSizes(input: {
requestedWidth?: number;
containerWidth: number;
}): number[] {
if (input.containerWidth <= 0) {
return [1, 0];
}
const width = resolveExplorerSidebarWidth(input);
const ratio = width / input.containerWidth;
return [1 - ratio, ratio];
}
@@ -1,6 +1,7 @@
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
import { Fragment, useMemo, type ReactElement } from "react";
import { withUnistyles } from "react-native-unistyles";
import {
ArrowRightToLine,
Copy,
CopyPlus,
Download,
@@ -25,8 +26,11 @@ import {
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
type FileActionGroup = "create" | "open" | "reference" | "manage" | "destructive";
interface FileAction {
key: string;
group: FileActionGroup;
label: string;
icon: LucideIcon;
onSelect: () => void;
@@ -35,10 +39,19 @@ interface FileAction {
testID?: string;
}
function optionalFileAction(
available: boolean,
onSelect: (() => void) | undefined,
action: Omit<FileAction, "onSelect">,
): FileAction | null {
return available && onSelect ? { ...action, onSelect } : null;
}
interface FileActionsContextMenuContentProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onOpenToSide?: () => void;
onCopyPath?: () => void;
onCopyRelativePath?: () => void;
onReveal?: () => void;
@@ -52,8 +65,6 @@ interface FileActionsContextMenuContentProps {
onDuplicate?: () => void;
onRevert?: () => void;
onDelete?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
testIDPrefix?: string;
}
@@ -65,6 +76,7 @@ export function FileActionsContextMenuContent({
fileKind,
fileExists = true,
onOpenFile,
onOpenToSide,
onCopyPath,
onCopyRelativePath,
onReveal,
@@ -78,7 +90,6 @@ export function FileActionsContextMenuContent({
onDuplicate,
onRevert,
onDelete,
header,
testIDPrefix,
}: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation();
@@ -88,6 +99,7 @@ export function FileActionsContextMenuContent({
onNewFile
? {
key: "new-file",
group: "create",
label: t("workspace.fileActions.newFile"),
icon: FilePlus,
onSelect: onNewFile,
@@ -96,6 +108,7 @@ export function FileActionsContextMenuContent({
onNewFolder
? {
key: "new-folder",
group: "create",
label: t("workspace.fileActions.newFolder"),
icon: FolderPlus,
onSelect: onNewFolder,
@@ -104,6 +117,7 @@ export function FileActionsContextMenuContent({
onCollapseFolder
? {
key: "collapse-folder",
group: "open",
label: t("workspace.fileActions.collapseFolder"),
icon: FolderMinus,
onSelect: onCollapseFolder,
@@ -112,30 +126,22 @@ export function FileActionsContextMenuContent({
availableFile && onOpenFile
? {
key: "open-file",
group: "open",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
}
: null,
onRename
? {
key: "rename",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
optionalFileAction(availableFile, onOpenToSide, {
key: "open-to-side",
group: "open",
label: t("workspace.fileActions.openToSide"),
icon: ArrowRightToLine,
}),
onCopyPath
? {
key: "copy-path",
group: "reference",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
@@ -144,6 +150,7 @@ export function FileActionsContextMenuContent({
onCopyRelativePath
? {
key: "copy-relative-path",
group: "reference",
label: t("workspace.fileActions.copyRelativePath"),
icon: Copy,
onSelect: onCopyRelativePath,
@@ -152,6 +159,7 @@ export function FileActionsContextMenuContent({
onReveal && revealTargetName
? {
key: "reveal",
group: "reference",
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
icon: FolderOpen,
onSelect: onReveal,
@@ -160,6 +168,7 @@ export function FileActionsContextMenuContent({
availableFile && onDownload
? {
key: "download",
group: "reference",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
@@ -168,14 +177,34 @@ export function FileActionsContextMenuContent({
availableFile && onAddToChat
? {
key: "add-to-chat",
group: "reference",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
}
: null,
onRename
? {
key: "rename",
group: "manage",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
group: "manage",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
onRevert
? {
key: "revert",
group: "destructive",
label: t("workspace.fileActions.revert"),
icon: Undo2,
onSelect: onRevert,
@@ -185,6 +214,7 @@ export function FileActionsContextMenuContent({
onDelete
? {
key: "delete",
group: "destructive",
label: t("workspace.fileActions.delete"),
icon: Trash2,
onSelect: onDelete,
@@ -195,9 +225,7 @@ export function FileActionsContextMenuContent({
const availableActions = specs.filter((action): action is FileAction => action !== null);
return availableActions.map((action, index) =>
Object.assign(action, {
separatorBefore: Boolean(
action.destructive && index > 0 && !availableActions[index - 1].destructive,
),
separatorBefore: index > 0 && action.group !== availableActions[index - 1]?.group,
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
}),
);
@@ -214,6 +242,7 @@ export function FileActionsContextMenuContent({
onNewFile,
onNewFolder,
onOpenFile,
onOpenToSide,
onRename,
onReveal,
onRevert,
@@ -231,12 +260,6 @@ export function FileActionsContextMenuContent({
width={220}
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
>
{header ? (
<>
{header}
<ContextMenuSeparator />
</>
) : null}
{actions.map((action) => (
<Fragment key={action.key}>
{action.separatorBefore ? <ContextMenuSeparator /> : null}
@@ -41,7 +41,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
paneContentToolbarTrailingPadding,
ToolbarButton,
ToolbarControls,
} from "@/components/ui/pane-content-toolbar";
import {
useOverlayFlatListScrollbar,
@@ -61,7 +63,6 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
@@ -100,16 +101,6 @@ function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expande
return <TreeChevron expanded={expanded} />;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
@@ -126,6 +117,7 @@ interface TreeRowItemProps {
revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -141,13 +133,6 @@ function sortTriggerStyle({
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
}
function iconButtonStyle(
isCompact: boolean,
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
type ExplorerListRow =
| { type: "entry"; row: ExplorerTreeRow }
| { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number }
@@ -256,6 +241,7 @@ function TreeRowItem({
revealTargetName,
onDownloadEntry,
onAddToChat,
onOpenFileToSide,
onNewEntry,
onCollapseDirectory,
onRenameEntry,
@@ -263,7 +249,6 @@ function TreeRowItem({
onDeleteEntry,
testID,
}: TreeRowItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const showNameHover = useCallback(() => setIsHovered(true), []);
const hideNameHover = useCallback(() => setIsHovered(false), []);
@@ -317,6 +302,10 @@ function TreeRowItem({
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const handleOpenToSide = useCallback(() => {
onOpenFileToSide?.(entry.path);
}, [entry.path, onOpenFileToSide]);
const handleNewFile = useCallback(() => {
onNewEntry?.(entry.path, "file");
}, [onNewEntry, entry.path]);
@@ -341,30 +330,6 @@ function TreeRowItem({
onDeleteEntry?.(entry);
}, [onDeleteEntry, entry]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
);
return (
<ContextMenu>
<ContextMenuTrigger
@@ -407,13 +372,13 @@ function TreeRowItem({
revealTargetName={revealTargetName}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
onOpenToSide={!isDirectory && onOpenFileToSide ? handleOpenToSide : undefined}
onNewFile={onNewEntry ? handleNewFile : undefined}
onNewFolder={onNewEntry ? handleNewFolder : undefined}
onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined}
onRename={onRenameEntry ? handleRename : undefined}
onDuplicate={onDuplicateEntry ? handleDuplicate : undefined}
onDelete={onDeleteEntry ? handleDelete : undefined}
header={metaHeader}
testIDPrefix={testID}
/>
</ContextMenu>
@@ -425,6 +390,7 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onOpenFileToSide?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
@@ -433,6 +399,7 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onOpenFileToSide,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
@@ -996,6 +963,7 @@ export function FileExplorerPane({
revealTargetName={fileManagerTarget?.label}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined}
onCollapseDirectory={handleCollapseDirectory}
onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined}
@@ -1026,6 +994,7 @@ export function FileExplorerPane({
fileManagerTarget,
selectedEntryPath,
onAddToChat,
onOpenFileToSide,
serverId,
workspaceId,
],
@@ -1049,11 +1018,6 @@ export function FileExplorerPane({
});
}, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -1087,7 +1051,6 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={toolbarIconButtonStyle}
/>
</View>
);
@@ -1162,7 +1125,6 @@ interface FileExplorerPaneContentProps {
handleBackFromError: () => void;
handleRetry: () => void;
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
iconButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
}
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
@@ -1186,7 +1148,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
handleBackFromError,
handleRetry,
sortTriggerStyle: sortTriggerStyleProp,
iconButtonStyle: iconButtonStyleProp,
} = props;
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
@@ -1204,17 +1165,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const emptyLabel = showHiddenFiles
? t("workspace.fileExplorer.empty.noFiles")
: t("workspace.fileExplorer.empty.noVisibleFiles");
const hiddenFilesToggleStyle = useCallback(
(state: PressableStateCallbackType) => [
iconButtonStyleProp(state),
!showHiddenFiles && styles.iconButtonActive,
],
[showHiddenFiles, iconButtonStyleProp],
);
const hiddenFilesToggleAccessibilityState = useMemo(
() => ({ selected: !showHiddenFiles }),
[showHiddenFiles],
);
if (error) {
return (
@@ -1245,7 +1195,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
<PaneContentToolbar
style={[styles.paneHeader, { paddingRight: paneContentToolbarTrailingPadding(isCompact) }]}
testID="files-pane-header"
>
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
@@ -1256,86 +1209,82 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
<ToolbarControls style={styles.headerActions}>
{onNewEntryAtRoot ? (
<>
<Pressable
onPress={handleNewFileAtRoot}
<ToolbarButton
label={t("workspace.fileActions.newFile")}
compact={isCompact}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file"
onPress={handleNewFileAtRoot}
>
<FilePlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
</Pressable>
<Pressable
onPress={handleNewFolderAtRoot}
</ToolbarButton>
<ToolbarButton
label={t("workspace.fileActions.newFolder")}
compact={isCompact}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder"
onPress={handleNewFolderAtRoot}
>
<FolderPlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
</Pressable>
</ToolbarButton>
</>
) : null}
<Pressable
onPress={handleToggleHiddenFiles}
<ToolbarButton
label={hiddenFilesToggleAccessibilityLabel}
selected={!showHiddenFiles}
compact={isCompact}
hitSlop={8}
style={hiddenFilesToggleStyle}
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
onPress={handleToggleHiddenFiles}
>
{showHiddenFiles ? (
<Eye
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
) : (
<EyeOff
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
)}
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
</ToolbarButton>
<ToolbarButton
label={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
compact={isCompact}
disabled={isRefreshFetching}
hitSlop={8}
testID="files-refresh"
onPress={handleRefresh}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
) : (
<RotateCw
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
)}
</View>
</Pressable>
</View>
</ToolbarButton>
</ToolbarControls>
</PaneContentToolbar>
<ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
@@ -1479,6 +1428,7 @@ function TreeRowDispatcher({
revealTargetName,
onDownloadEntry,
onAddToChat,
onOpenFileToSide,
onNewEntry,
onCollapseDirectory,
onRenameEntry,
@@ -1500,6 +1450,7 @@ function TreeRowDispatcher({
revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -1530,6 +1481,7 @@ function TreeRowDispatcher({
revealTargetName={revealTargetName}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={onNewEntry}
onCollapseDirectory={onCollapseDirectory}
onRenameEntry={onRenameEntry}
@@ -1698,7 +1650,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
backgroundColor: theme.colors.surfaceSidebar,
},
sortTrigger: {
flexDirection: "row",
@@ -1711,7 +1663,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base,
},
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surfaceSidebarHover,
},
sortTriggerText: {
fontSize: theme.fontSize.sm,
@@ -1720,7 +1672,6 @@ const styles = StyleSheet.create((theme) => ({
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
treeList: {
flex: 1,
@@ -1807,38 +1758,12 @@ const styles = StyleSheet.create((theme) => ({
draftPlaceholder: {
color: theme.colors.foregroundExtraMuted,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
contextMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 32,
paddingHorizontal: theme.spacing[3],
},
contextMetaLabel: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
flexShrink: 0,
},
contextMetaValue: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
flex: 1,
minWidth: 0,
textAlign: "right",
},
previewHeaderText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: 16,
height: 16,
@@ -5,6 +5,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
hovered: boolean;
@@ -42,7 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]);
const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -68,16 +82,6 @@ export function HeaderToggleButton({
);
}
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
slot: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
const styles = StyleSheet.create((theme) => ({
tooltipRow: {
flexDirection: "row",
@@ -90,3 +94,7 @@ const styles = StyleSheet.create((theme) => ({
},
shortcut: {},
}));
export const headerIconSlotStyle = {
slot: iconButtonChromeFrameStyle("large"),
};
@@ -10,6 +10,7 @@ import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -26,7 +27,7 @@ interface SidebarMenuToggleProps {
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
@@ -84,19 +85,21 @@ function SidebarMenuToggleButton({
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{({ hovered, pressed }) => {
let color = extraMutedIdleIcon
? theme.colors.foregroundExtraMuted
: theme.colors.foregroundMuted;
if (hovered || pressed) {
color = theme.colors.foreground;
}
return isMobile ? (
<MobileMenuIcon color={color} />
) : (
<PanelLeft size={theme.iconSize.md} color={color} />
);
}}
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
+1 -1
View File
@@ -1462,7 +1462,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
fileLinkActions.open({ href: url }, "side");
fileLinkActions.open({ href: url }, "preferred");
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
+128 -19
View File
@@ -1,14 +1,22 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
export interface ResizeHandleProps {
testID?: string;
direction: "horizontal" | "vertical";
hitAreaAlignment?: "center" | "end";
groupId: string;
index: number;
sizes: number[];
containerSize: number;
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
@@ -30,14 +38,18 @@ function resetWindowHorizontalScroll() {
export function ResizeHandle({
testID,
direction,
hitAreaAlignment = "center",
groupId,
index,
sizes,
containerSize,
onPreviewResizeSplit,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -51,13 +63,6 @@ export function ResizeHandle({
return;
}
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) {
return;
}
@@ -140,9 +145,41 @@ export function ResizeHandle({
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
[containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
);
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
})
.onUpdate((event) => {
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
})
.onEnd(() => touchDragRef.current?.finish())
.onFinalize(() => {
touchDragRef.current = null;
setDragging(false);
});
return direction === "horizontal"
? gesture
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
: gesture
.activeOffsetY([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetX([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]);
}, [containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes]);
const handlePointerEnter = useCallback(() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
@@ -178,11 +215,36 @@ export function ResizeHandle({
styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
...(direction === "horizontal"
? { left: hitAreaAlignment === "end" ? 0 : -5 }
: { top: hitAreaAlignment === "end" ? 0 : -5 }),
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
touchAction: "none",
} as object,
],
[direction],
[direction, hitAreaAlignment],
);
const touchHitAreaStyle = useMemo(
() => [
styles.touchHitArea,
direction === "horizontal" ? styles.touchHitAreaHorizontal : styles.touchHitAreaVertical,
{
...(direction === "horizontal"
? { left: hitAreaAlignment === "end" ? 0 : -12 }
: { top: hitAreaAlignment === "end" ? 0 : -12 }),
touchAction: "none",
} as object,
],
[direction, hitAreaAlignment],
);
const touchGripStyle = useMemo(
() => [
styles.touchGrip,
direction === "horizontal" ? styles.touchGripHorizontal : styles.touchGripVertical,
highlighted ? styles.touchGripVisible : styles.touchGripHidden,
{ backgroundColor: theme.colors.foreground },
],
[direction, highlighted, theme.colors.foreground],
);
return (
@@ -194,14 +256,27 @@ export function ResizeHandle({
testID={testID ? `${testID}-highlight` : undefined}
/>
)}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
{finePointer ? (
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
) : (
<GestureDetector gesture={touchGesture}>
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
collapsable={false}
style={touchHitAreaStyle}
>
<View pointerEvents="none" style={touchGripStyle} />
</View>
</GestureDetector>
)}
</View>
);
}
@@ -210,6 +285,7 @@ const styles = StyleSheet.create((_theme) => ({
handle: {
position: "relative",
flexShrink: 0,
zIndex: 10,
},
handleHorizontal: {
width: 1,
@@ -240,15 +316,48 @@ const styles = StyleSheet.create((_theme) => ({
zIndex: 10,
},
hitAreaHorizontal: {
left: -5,
top: 0,
bottom: 0,
width: 10,
},
hitAreaVertical: {
top: -5,
left: 0,
right: 0,
height: 10,
},
touchHitArea: {
position: "absolute",
zIndex: 10,
alignItems: "center",
justifyContent: "center",
},
touchHitAreaHorizontal: {
top: "50%",
width: 24,
height: 88,
transform: [{ translateY: -44 }],
},
touchHitAreaVertical: {
left: "50%",
width: 88,
height: 24,
transform: [{ translateX: -44 }],
},
touchGrip: {
borderRadius: 2,
},
touchGripHorizontal: {
width: 4,
height: 36,
},
touchGripVertical: {
width: 36,
height: 4,
},
touchGripHidden: {
opacity: 0.12,
},
touchGripVisible: {
opacity: 0.3,
},
}));
@@ -37,14 +37,7 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
ExternalLink,
GitPullRequest,
Settings,
MoreVertical,
Plus,
Trash2,
} from "lucide-react-native";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
@@ -159,6 +152,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -166,7 +160,6 @@ const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKe
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
@@ -179,26 +172,6 @@ const foregroundColorMapping = (theme: Theme) => ({
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({
color: theme.colors.statusDanger,
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.statusSuccess,
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.statusMerged,
});
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null;
@@ -352,7 +325,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
const textStyle = isHovered
? [prBadgeStyles.text, prBadgeStyles.textHovered]
: prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
return (
@@ -370,9 +342,9 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
style={pressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
<ThemedExternalLink size={12} uniProps={foregroundColorMapping} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
<PullRequestStateIcon state={hint.state} size={12} />
)}
<Text style={textStyle} numberOfLines={1}>
{hint.number}
@@ -2,15 +2,7 @@ import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ExternalLink,
Folder,
GitBranch,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Globe,
} from "lucide-react-native";
import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
import {
workspaceLabelKey,
type WorkspaceLabelDefinition,
@@ -23,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -44,9 +37,6 @@ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedFolder = withUnistyles(Folder);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
/** Stable identity so a row without labels doesn't re-select its items on every render. */
@@ -54,7 +44,6 @@ const EMPTY_LABELS: readonly WorkspaceLabelDefinition[] = [];
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
/**
@@ -212,7 +201,6 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
return (
<Pressable
accessibilityRole="link"
@@ -227,10 +215,11 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
onHoverOut={handleHoverOut}
style={pressableItemStyle}
>
<Icon
size={META_ICON_SIZE}
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
/>
{isHovered ? (
<ThemedExternalLink size={META_ICON_SIZE} uniProps={foregroundMapping} />
) : (
<PullRequestStateIcon state={hint.state} size={META_ICON_SIZE} />
)}
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
{hint.number}
{/* An open change request is the unremarkable case and says nothing extra; a merged
@@ -311,20 +300,6 @@ function ServiceItem({ summary }: { summary: WorkspaceServiceSummary }) {
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const PR_ICONS = {
open: ThemedGitPullRequest,
merged: ThemedGitMerge,
closed: ThemedGitPullRequestClosed,
} as const;
// Same three colours the hover card gives the same three states — a change request has one
// identity, and it shouldn't shift when you hover the row that names it.
const PR_COLOR_MAPPINGS = {
open: successMapping,
merged: mergedMapping,
closed: dangerMapping,
} as const;
const PR_STATE_LABEL_KEYS = {
merged: "workspace.git.pr.states.merged",
closed: "workspace.git.pr.states.closed",
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
import {
resolveSplitContainerRoot,
splitNodeContainsPane,
} from "@/components/split-container-focus";
import type { SplitNode } from "@/stores/workspace-layout-store";
const pane = (id: string): SplitNode => ({
@@ -35,25 +38,11 @@ describe("split focus root", () => {
).toEqual({ root, usesFallbackStrip: false });
});
it("renders only a maximized pane without entering focus mode", () => {
it("finds the maximized pane without replacing the rendered split tree", () => {
expect(splitNodeContainsPane(root, "right")).toBe(true);
expect(splitNodeContainsPane(root, "missing")).toBe(false);
expect(
resolveSplitContainerRoot({
root,
focusedPaneId: "left",
focusModeEnabled: false,
maximizedPaneId: "right",
}),
).toEqual({ root: pane("right"), usesFallbackStrip: false });
});
it("keeps normal splits when the maximized pane is unavailable", () => {
expect(
resolveSplitContainerRoot({
root,
focusedPaneId: "left",
focusModeEnabled: false,
maximizedPaneId: "missing",
}),
resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }),
).toEqual({ root, usesFallbackStrip: false });
});
});
@@ -4,9 +4,8 @@ export function resolveSplitContainerRoot(input: {
root: SplitNode;
focusedPaneId: string | null;
focusModeEnabled: boolean | undefined;
maximizedPaneId?: string | null;
}): { root: SplitNode; usesFallbackStrip: boolean } {
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : input.maximizedPaneId;
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : null;
if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false };
const isolatedPane = findPane(input.root, isolatedPaneId);
if (!isolatedPane || isolatedPane.hidden === true) {
@@ -15,6 +14,12 @@ export function resolveSplitContainerRoot(input: {
return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false };
}
/** Whether a split subtree contains the pane currently projected full-size. */
export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean {
if (node.kind === "pane") return node.pane.id === paneId;
return node.group.children.some((child) => splitNodeContainsPane(child, paneId));
}
function findPane(node: SplitNode, paneId: string): SplitPane | null {
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
for (const child of node.group.children) {
+374 -250
View File
@@ -1,6 +1,5 @@
import {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
@@ -27,7 +26,7 @@ import {
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native";
import { View, Text, type LayoutChangeEvent } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -36,10 +35,18 @@ import Animated, {
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { RetainedPanel } from "@/components/retained-panel";
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
import {
resolveSplitContainerRoot,
splitNodeContainsPane,
} from "@/components/split-container-focus";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import {
removeWindowChromeCorner,
WindowChromeRegion,
WindowChromeSafeArea,
useWindowChromeCorners,
@@ -59,22 +66,20 @@ import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
createDefaultLayout,
findPaneById,
useWorkspaceLayoutStore,
type SplitNode,
@@ -83,13 +88,13 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
interface SplitContainerProps {
layout: WorkspaceLayout;
renderMainHeader?: () => ReactNode;
renderExplorerSidebarHeaderAction?: () => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -125,6 +130,7 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onSelectTabInPane: (paneId: string, tabId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
focusModeEnabled?: boolean;
@@ -144,6 +150,7 @@ interface SplitPaneDropData {
const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = [];
const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar";
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
@@ -165,18 +172,21 @@ function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData
return undefined;
}
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
interface SplitNodeViewProps extends Omit<
SplitContainerProps,
"layout" | "onMoveTabToPane" | "onSelectTabInPane"
> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string | null;
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
windowChromeCorners: WindowChromeCorners;
maximizedPaneId: string | null;
workspaceHasSplits: boolean;
onTogglePaneMaximized: (paneId: string) => void;
}
interface SplitPaneViewProps extends Omit<
@@ -199,82 +209,6 @@ interface SplitPaneViewProps extends Omit<
tabDropPreview: TabDropPreview | null;
}
interface MountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string;
onFocusPane: (paneId: string) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
const MountedTabSlot = memo(function MountedTabSlot({
tabDescriptor,
isVisible,
isWorkspaceFocused,
isPaneFocused,
paneId,
onFocusPane,
buildPaneContentModel,
}: MountedTabSlotProps) {
const content = useMemo(
() =>
buildPaneContentModel({
paneId,
tab: tabDescriptor,
}),
[buildPaneContentModel, paneId, tabDescriptor],
);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<RetainedPanel active={isVisible}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</RetainedPanel>
</RenderProfile>
);
});
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
const next = new Map<string, WorkspaceTabDescriptor>();
for (const tabDescriptor of tabDescriptors) {
const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
if (
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
cachedDescriptor.state === tabDescriptor.state &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
continue;
}
next.set(tabDescriptor.tabId, tabDescriptor);
}
return next;
}, [tabDescriptors]);
useEffect(() => {
cacheRef.current = tabDescriptorMap;
}, [tabDescriptorMap]);
return tabDescriptorMap;
}
interface DragMoveRects {
translatedRect: { left: number; top: number; width: number; height: number };
overRect: { left: number; top: number; width: number; height: number };
@@ -372,6 +306,8 @@ const dropCollisionDetection: CollisionDetection = (args) => {
export function SplitContainer({
layout,
renderMainHeader,
renderExplorerSidebarHeaderAction,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
@@ -397,6 +333,7 @@ export function SplitContainer({
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onSelectTabInPane,
onResizeSplit,
onReorderTabsInPane,
focusModeEnabled,
@@ -413,56 +350,10 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const sidePanelPaneId = useWorkspaceLayoutStore(
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
);
useEffect(() => {
if (!maximizedPaneId) {
return;
}
const isolatedPane = findPaneById(layout.root, maximizedPaneId);
if (focusModeEnabled || !isolatedPane || isolatedPane.hidden === true) {
setMaximizedPane(null);
}
}, [focusModeEnabled, layout.root, maximizedPaneId]);
const handleTogglePaneMaximized = useCallback(
(paneId: string) => {
setMaximizedPane((currentPane) =>
currentPane?.workspaceKey === workspaceKey && currentPane.paneId === paneId
? null
: { workspaceKey, paneId },
);
},
[workspaceKey],
);
const handleExplorerMaximizeAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "workspace.explorer.maximize.toggle") {
return false;
}
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
return true;
}
onFocusPane(explorerPane.id);
handleTogglePaneMaximized(explorerPane.id);
return true;
},
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
);
useKeyboardActionHandler({
handlerId: `workspace-explorer-maximize:${workspaceKey}`,
actions: ["workspace.explorer.maximize.toggle"] as const,
enabled: isWorkspaceFocused,
priority: 100,
isActive: () => true,
handle: handleExplorerMaximizeAction,
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -475,15 +366,122 @@ export function SplitContainer({
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const explorerSidebarPane = useMemo(
() => findPaneById(layout.root, explorerSidebarPaneId),
[layout.root, explorerSidebarPaneId],
);
const mainRoot = useMemo(
() => removePaneFromSplitTree(layout.root, explorerSidebarPaneId),
[layout.root, explorerSidebarPaneId],
);
const workspaceHasSplits = mainRoot?.kind === "group";
useEffect(() => {
if (
maximizedPaneId &&
(focusModeEnabled ||
!workspaceHasSplits ||
!mainRoot ||
!splitNodeContainsPane(mainRoot, maximizedPaneId))
) {
setMaximizedPane(null);
}
}, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasSplits]);
const handleTogglePaneMaximized = useCallback(
(paneId: string) => {
setMaximizedPane((current) =>
current?.workspaceKey === workspaceKey && current.paneId === paneId
? null
: { workspaceKey, paneId },
);
},
[workspaceKey],
);
const handleCreateExplorerTab = useCallback(
() => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }),
[explorerSidebarPaneId, onCreateNewTab],
);
const handleMoveExplorerTabToMain = useCallback(
(tabId: string) => {
if (layout.focusedPaneId) {
onMoveTabToPane(tabId, layout.focusedPaneId);
}
},
[layout.focusedPaneId, onMoveTabToPane],
);
const splitRoot = useMemo(
() =>
resolveSplitContainerRoot({
root: layout.root,
root: mainRoot ?? createDefaultLayout().root,
focusedPaneId: layout.focusedPaneId,
focusModeEnabled,
maximizedPaneId,
}),
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
[focusModeEnabled, layout.focusedPaneId, mainRoot],
);
const storedExplorerSidebarWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[workspaceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0);
const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState<number | null>(
null,
);
const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth;
const explorerSidebarWidth = resolveExplorerSidebarWidth({
requestedWidth: requestedExplorerSidebarWidth,
containerWidth: workspaceShellWidth,
});
const explorerSidebarDockSizes = useMemo(
() =>
resolveExplorerSidebarDockSizes({
requestedWidth: requestedExplorerSidebarWidth,
containerWidth: workspaceShellWidth,
}),
[requestedExplorerSidebarWidth, workspaceShellWidth],
);
const renderExplorerSidebarDock = Boolean(
!focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true,
);
const mainColumnWindowChromeCorners = renderExplorerSidebarDock
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const explorerSidebarDockStyle = useMemo(
() => [styles.explorerSidebarDock, { width: explorerSidebarWidth }],
[explorerSidebarWidth],
);
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const previewExplorerSidebarResize = useCallback(
(_groupId: string, sizes: number[]) => {
const nextRatio = sizes[1];
if (nextRatio !== undefined) {
setPreviewExplorerSidebarWidth(
resolveExplorerSidebarWidth({
requestedWidth: nextRatio * workspaceShellWidth,
containerWidth: workspaceShellWidth,
}),
);
}
},
[workspaceShellWidth],
);
const commitExplorerSidebarResize = useCallback(
(_groupId: string, sizes: number[]) => {
setPreviewExplorerSidebarWidth(null);
const nextRatio = sizes[1];
if (nextRatio !== undefined) {
resizeExplorerSidebar(
workspaceKey,
resolveExplorerSidebarWidth({
requestedWidth: nextRatio * workspaceShellWidth,
containerWidth: workspaceShellWidth,
}),
);
}
},
[resizeExplorerSidebar, workspaceKey, workspaceShellWidth],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -521,6 +519,21 @@ export function SplitContainer({
return;
}
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
const destinationPaneId =
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
? overData.paneId
: null;
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
if (
!activeTab ||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const preview = computeTabOverDropPreview({
activeData,
@@ -542,7 +555,7 @@ export function SplitContainer({
setDropPreview(computePaneOverDropPreview({ overData, rects }));
},
[panesById, uiTabs],
[normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
);
const applyTabDropEnd = useCallback(
@@ -639,47 +652,90 @@ export function SplitContainer({
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
<View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
) : null}
</View>
</WindowChromeRegion>
{renderExplorerSidebarDock && explorerSidebarPane ? (
<>
<ResizeHandle
testID="workspace-explorer-sidebar-resize-handle"
direction="horizontal"
hitAreaAlignment="end"
groupId={EXPLORER_SIDEBAR_RESIZE_GROUP_ID}
index={0}
sizes={explorerSidebarDockSizes}
containerSize={workspaceShellWidth}
onPreviewResizeSplit={previewExplorerSidebarResize}
onResizeSplit={commitExplorerSidebarResize}
/>
<View style={explorerSidebarDockStyle}>
<ExplorerSidebarDock
pane={explorerSidebarPane}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
closingTabIds={closingTabIds}
onSelectTab={onSelectTabInPane}
onCloseTab={onCloseTab}
onCreateNewTab={handleCreateExplorerTab}
onMoveTabToMain={handleMoveExplorerTabToMain}
buildPaneContentModel={buildPaneContentModel}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
tabDropPreview={tabDropPreview}
headerAction={renderExplorerSidebarHeaderAction?.()}
/>
</View>
</>
) : null}
</View>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
@@ -825,16 +881,35 @@ function SplitGroupChild({
* hidden pane give its space back instead of just going invisible. The stored `sizes` are never
* touched, so unhiding restores the width the user dragged to.
*/
function resolveVisibleGroupFlex(children: SplitNode[], sizes: number[]): number[] {
function resolveVisibleGroupFlex(
children: SplitNode[],
sizes: number[],
maximizedPaneId: string | null,
): number[] {
const visibleTotal = children.reduce(
(total, child, index) => (isSplitNodeHidden(child) ? total : total + (sizes[index] ?? 1)),
(total, child, index) =>
isSplitNodeHiddenForPresentation(child, maximizedPaneId)
? total
: total + (sizes[index] ?? 1),
0,
);
if (visibleTotal <= 0) {
return children.map(() => 0);
}
return children.map((child, index) =>
isSplitNodeHidden(child) ? 0 : (sizes[index] ?? 1) / visibleTotal,
isSplitNodeHiddenForPresentation(child, maximizedPaneId)
? 0
: (sizes[index] ?? 1) / visibleTotal,
);
}
function isSplitNodeHiddenForPresentation(
node: SplitNode,
maximizedPaneId: string | null,
): boolean {
return (
isSplitNodeHidden(node) ||
Boolean(maximizedPaneId && !splitNodeContainsPane(node, maximizedPaneId))
);
}
@@ -850,9 +925,6 @@ function SplitNodeView({
workspaceKey,
uiTabs,
focusedPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
@@ -882,9 +954,13 @@ function SplitNodeView({
dropPreview,
tabDropPreview,
windowChromeCorners,
maximizedPaneId,
workspaceHasSplits,
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
}: SplitNodeViewProps) {
const [groupContainerSize, setGroupContainerSize] = useState(0);
const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null;
@@ -895,8 +971,8 @@ function SplitNodeView({
const groupSizes =
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes),
[groupChildren, groupSizes],
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
[groupChildren, groupSizes, maximizedPaneId],
);
const resizeFlex = useSharedValue(visibleFlex);
useEffect(() => {
@@ -904,9 +980,19 @@ function SplitNodeView({
}, [resizeFlex, visibleFlex]);
const previewResizeSplit = useCallback(
(_groupId: string, sizes: number[]) => {
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes);
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes, maximizedPaneId);
},
[groupChildren, resizeFlex],
[groupChildren, maximizedPaneId, resizeFlex],
);
const handleGroupLayout = useCallback(
(event: LayoutChangeEvent) => {
const nextSize =
groupDirection === "horizontal"
? event.nativeEvent.layout.width
: event.nativeEvent.layout.height;
setGroupContainerSize((current) => (current === nextSize ? current : nextSize));
},
[groupDirection],
);
const groupStyle = useMemo(
@@ -919,15 +1005,14 @@ function SplitNodeView({
if (node.kind === "pane") {
return (
<RetainedPanel active={node.pane.hidden !== true}>
<RetainedPanel
active={node.pane.hidden !== true && (!maximizedPaneId || node.pane.id === maximizedPaneId)}
>
<WindowChromeRegion corners={windowChromeCorners}>
<SplitPaneView
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
@@ -955,6 +1040,9 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
@@ -964,18 +1052,19 @@ function SplitNodeView({
}
return (
<View style={groupStyle}>
<View style={groupStyle} onLayout={handleGroupLayout}>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}>
<SplitGroupChild
resizeFlex={resizeFlex}
index={index}
hidden={isSplitNodeHiddenForPresentation(child, maximizedPaneId)}
>
<SplitNodeView
node={child}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
@@ -1005,19 +1094,23 @@ function SplitNodeView({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 &&
!isSplitNodeHidden(child) &&
!isSplitNodeHidden(node.group.children[index + 1]) ? (
!isSplitNodeHiddenForPresentation(child, maximizedPaneId) &&
!isSplitNodeHiddenForPresentation(node.group.children[index + 1], maximizedPaneId) ? (
<ResizeHandle
testID="workspace-split-resize-handle"
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={groupSizes}
containerSize={groupContainerSize}
onPreviewResizeSplit={previewResizeSplit}
onResizeSplit={onResizeSplit}
/>
@@ -1053,19 +1146,18 @@ function SplitPaneView({
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
onSplitPaneEmpty,
onReorderTabsInPane,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
sidePanelPaneId,
maximizedPaneId,
workspaceHasSplits,
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) {
const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const paneState = useMemo(
@@ -1077,24 +1169,7 @@ function SplitPaneView({
[pane, uiTabs],
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
const modifiedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: paneTabIds,
});
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const { mountedTabIds } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: paneTabIds,
retainedTabIds: modifiedPaneTabIds,
cap: 3,
});
const mountedPaneTabIds = useMemo(
() => paneTabIds.filter((tabId) => mountedTabIds.has(tabId)),
[mountedTabIds, paneTabIds],
);
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
paneTabs.map((tab) => ({
@@ -1162,7 +1237,15 @@ function SplitPaneView({
},
[onReorderTabsInPane, paneId],
);
const handleToggleMaximized = useCallback(
const handleSplitRight = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }),
[onSplitPaneEmpty, paneId],
);
const handleSplitDown = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }),
[onSplitPaneEmpty, paneId],
);
const handleTogglePaneMaximized = useCallback(
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
@@ -1172,7 +1255,7 @@ function SplitPaneView({
ref={paneRef}
collapsable={false}
style={styles.pane}
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
testID={`workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
@@ -1201,34 +1284,29 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
paneMaximized={pane.id === maximizedPaneId}
onTogglePaneMaximized={handleToggleMaximized}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasSplits && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
<View style={styles.paneContent}>
{mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})}
<WorkspacePanelHost
paneId={pane.id}
tabs={paneTabs}
activeTabId={activeTabDescriptor?.tabId ?? null}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
@@ -1274,7 +1352,53 @@ function wrapRootPaneForStableMount(node: SplitNode): SplitNode {
};
}
function removePaneFromSplitTree(node: SplitNode, paneId: string | null): SplitNode | null {
if (!paneId) {
return node;
}
if (node.kind === "pane") {
return node.pane.id === paneId ? null : node;
}
const children = node.group.children.flatMap((child) => {
const nextChild = removePaneFromSplitTree(child, paneId);
return nextChild ? [nextChild] : [];
});
if (children.length === 0) {
return null;
}
if (children.length === 1) {
return children[0] ?? null;
}
return {
kind: "group",
group: {
...node.group,
children,
sizes: children.map(() => 1 / children.length),
},
};
}
const styles = StyleSheet.create((theme) => ({
workspaceShell: {
flex: 1,
minWidth: 0,
minHeight: 0,
flexDirection: "row",
},
mainColumn: {
flex: 1,
flexShrink: 1,
minWidth: 0,
minHeight: 0,
},
explorerSidebarDock: {
flexShrink: 0,
minWidth: 240,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
group: {
flex: 1,
minWidth: 0,
@@ -18,7 +18,8 @@ export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
* The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs.
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
/** Shared painted-edge rail for pane headers, toolbars, and tree/diff rows. */
export const WORKSPACE_PANE_TRAILING_GLYPH_RAIL = SPACING[2];
/** Left padding for a row in either workspace tree. */
export function treeRowPaddingLeft(depth: number): number {
@@ -49,7 +50,7 @@ export const workspaceTreeRowStyles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
paddingRight: WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
},
active: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1,18 +1,10 @@
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";
import { withUnistyles } from "react-native-unistyles";
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
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
@@ -29,37 +21,18 @@ export function TreeRailToggle({
}) {
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>
<ToolbarButton
label={label}
selected={visible}
aria-selected={visible}
testID={testID}
onPress={onToggle}
>
<ThemedFolderTree
size={paneContentToolbarIconSize(false)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
);
}
const styles = StyleSheet.create((theme) => ({
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
}));
@@ -13,6 +13,7 @@ import {
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { isNative, isWeb } from "@/constants/platform";
@@ -98,6 +99,7 @@ type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => Sty
export function ContextMenuTrigger({
children,
contextOnly = false,
disabled,
highlightStyle,
style,
@@ -118,6 +120,7 @@ export function ContextMenuTrigger({
longPressDelayMs?: number;
onContextMenu?: (event: unknown) => void;
triggerRef?: Ref<View | null>;
contextOnly?: boolean;
}
>): ReactElement {
const ctx = useMenuContext("ContextMenuTrigger");
@@ -188,6 +191,25 @@ export function ContextMenuTrigger({
[style, ctx.open],
);
if (contextOnly) {
const contextOnlyStyle =
typeof style === "function"
? style({ pressed: false, hovered: false, open: ctx.open })
: style;
return (
<View
{...(props as ViewProps)}
ref={handleRef}
collapsable={false}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
style={contextOnlyStyle}
>
{children}
</View>
);
}
return (
<PressHighlight
{...props}
@@ -25,6 +25,7 @@ export interface ControlInteractionStyleMap {
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
export const HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
@@ -0,0 +1,115 @@
import { useCallback, useId } from "react";
import type { LayoutChangeEvent } from "react-native";
import Animated, {
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type AnimatedStyle,
} from "react-native-reanimated";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
const EDGE_EPSILON = 1;
const SHADE_WIDTH = 24;
function ScrollBoundaryShadeSvg({ side, color }: { side: "left" | "right"; color: string }) {
const gradientId = `horizontal-scroll-boundary-${side}-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<LinearGradient
id={gradientId}
x1={side === "left" ? "100%" : "0%"}
y1="0%"
x2={side === "left" ? "0%" : "100%"}
y2="0%"
>
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</LinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedScrollBoundaryShadeSvg = withUnistyles(ScrollBoundaryShadeSvg);
const surfaceColorMapping = (theme: Theme) => ({ color: theme.colors.surface0 });
const sidebarColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebar });
export function useHorizontalScrollBoundary() {
const offset = useSharedValue(0);
const viewportWidth = useSharedValue(0);
const contentWidth = useSharedValue(0);
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportWidth.value = event.nativeEvent.layout.width;
},
[viewportWidth],
);
const onContentSizeChange = useCallback(
(width: number) => {
contentWidth.value = width;
},
[contentWidth],
);
const onScroll = useAnimatedScrollHandler((event) => {
offset.value = event.contentOffset.x;
viewportWidth.value = event.layoutMeasurement.width;
contentWidth.value = event.contentSize.width;
});
const leftShadeStyle = useAnimatedStyle(() => ({
opacity: Number(offset.value > EDGE_EPSILON),
}));
const rightShadeStyle = useAnimatedStyle(() => ({
opacity: Number(offset.value + viewportWidth.value < contentWidth.value - EDGE_EPSILON),
}));
return { onLayout, onContentSizeChange, onScroll, leftShadeStyle, rightShadeStyle };
}
export function HorizontalScrollBoundaryShades({
visible,
backdrop,
leftStyle,
rightStyle,
testIDPrefix = "horizontal-scroll-boundary",
}: {
visible: boolean;
backdrop: "surface" | "sidebar";
leftStyle: AnimatedStyle<{ opacity: number }>;
rightStyle: AnimatedStyle<{ opacity: number }>;
testIDPrefix?: string;
}) {
if (!visible) return null;
const colorMapping = backdrop === "sidebar" ? sidebarColorMapping : surfaceColorMapping;
return (
<>
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-left`}
style={[styles.shade, styles.left, leftStyle]}
>
<ThemedScrollBoundaryShadeSvg side="left" uniProps={colorMapping} />
</Animated.View>
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-right`}
style={[styles.shade, styles.right, rightStyle]}
>
<ThemedScrollBoundaryShadeSvg side="right" uniProps={colorMapping} />
</Animated.View>
</>
);
}
const styles = StyleSheet.create(() => ({
shade: {
position: "absolute",
top: 0,
bottom: 1,
width: SHADE_WIDTH,
},
left: { left: 0 },
right: { right: 0 },
}));
@@ -0,0 +1,113 @@
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ICON_SIZE } from "@/styles/theme";
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
export interface IconButtonChromeState {
hovered?: boolean;
pressed?: boolean;
open?: boolean;
active?: boolean;
}
function resolveIconButtonFrame(size: IconButtonChromeSize, compact: boolean) {
if (size === "large") return styles.large;
return compact ? styles.smallCompact : styles.small;
}
interface IconButtonChromeOptions {
size: IconButtonChromeSize;
state?: IconButtonChromeState;
compact?: boolean;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
}
/** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
export function iconButtonChromeStyle({
size,
state,
compact = false,
disabled = false,
style,
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
return [
resolveIconButtonFrame(size, compact),
style,
highlighted ? styles.highlighted : null,
disabled ? styles.disabled : null,
];
}
/** Frame-only form for non-interactive placeholders that must preserve header alignment. */
export function iconButtonChromeFrameStyle(
size: IconButtonChromeSize,
compact = false,
): StyleProp<ViewStyle> {
return resolveIconButtonFrame(size, compact);
}
export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
if (size === "large") return ICON_SIZE.md;
return compact ? 18 : 14;
}
export function smallIconButtonChromeFrameSize(compact = false): number {
return compact ? COMPACT_SMALL_ICON_BUTTON_SIZE : SMALL_ICON_BUTTON_SIZE;
}
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
},
small: {
width: SMALL_ICON_BUTTON_SIZE,
height: SMALL_ICON_BUTTON_SIZE,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
},
smallCompact: {
width: COMPACT_SMALL_ICON_BUTTON_SIZE,
height: COMPACT_SMALL_ICON_BUTTON_SIZE,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
},
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
disabled: {
opacity: theme.opacity[50],
},
}));
@@ -0,0 +1,9 @@
import type { Theme } from "@/styles/theme";
export const mutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export const extraMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
@@ -26,6 +26,7 @@ export {
type ActionStatus,
type MenuItemProps,
} from "./menu-item";
export { MENU_ITEM_HEIGHT } from "./menu-geometry";
export {
useMenuContext,
useMenuDepth,
@@ -0,0 +1,2 @@
/** Shared geometry for menu rows and controls that align with them. */
export const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
@@ -20,6 +20,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
import { MenuDepthProvider, useMenuContext } from "./menu-context";
import { MENU_ITEM_HEIGHT } from "./menu-geometry";
const ThemedCheck = withUnistyles(Check);
const ThemedCheckCircle = withUnistyles(CheckCircle);
@@ -42,7 +43,6 @@ const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[50
* `lineHeight` to the platform and the content outgrows `minHeight`, which then does nothing and
* the rows drift taller again. On compact, `minHeight` leads instead and the label centres in it.
*/
const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
const MENU_ITEM_LINE_HEIGHT = 18;
/**
@@ -1,7 +1,24 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useMemo, type ReactNode } from "react";
import {
Text,
View,
type PressableProps,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
/** Shared chrome at the boundary between a workspace pane's tabs and content. */
export function PaneContentToolbar({
@@ -20,18 +37,144 @@ export function PaneContentToolbar({
);
}
export function paneContentToolbarIconSize(isCompact: boolean): number {
return isCompact ? 18 : 14;
/** A consistently spaced group of controls within any workspace toolbar or tab track. */
export function ToolbarControls({
children,
style,
...props
}: ViewProps & { children: ReactNode }) {
return (
<View {...props} style={[styles.controls, style]}>
{children}
</View>
);
}
interface ToolbarButtonCommonProps extends Omit<
PressableProps,
"accessibilityLabel" | "accessibilityRole" | "children" | "style"
> {
children: ReactNode;
label: string;
selected?: boolean;
compact?: boolean;
shortcut?: ShortcutKey[][] | null;
tooltipSide?: "left" | "right" | "top" | "bottom";
style?: StyleProp<ViewStyle>;
}
type ToolbarButtonProps = ToolbarButtonCommonProps &
(
| { kind?: "action"; onPress: NonNullable<PressableProps["onPress"]> }
| { kind: "menu"; onPress?: never }
);
/**
* The icon action used by pane toolbars and tab tracks. It owns the hitbox,
* interaction states, tooltip, and action/menu trigger composition.
*/
export function ToolbarButton({
children,
label,
selected = false,
compact = false,
shortcut,
tooltipSide = "bottom",
style,
kind = "action",
onPress,
disabled,
testID,
...props
}: ToolbarButtonProps) {
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) =>
iconButtonChromeStyle({
size: "small",
compact,
state: { ...state, active: selected },
disabled: Boolean(disabled),
style,
}),
[compact, disabled, selected, style],
);
const accessibilityState = useMemo(
() => ({ disabled: Boolean(disabled), selected }),
[disabled, selected],
);
const tooltip = (
<TooltipContent side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{shortcut ? <Shortcut chord={shortcut} /> : null}
</View>
</TooltipContent>
);
if (kind === "menu") {
return (
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger
{...props}
disabled={disabled}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
style={buttonStyle}
>
{children}
</DropdownMenuTrigger>
</TooltipTrigger>
{tooltip}
</Tooltip>
);
}
return (
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
{...props}
disabled={disabled}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
onPress={onPress}
style={buttonStyle}
>
{children}
</TooltipTrigger>
{tooltip}
</Tooltip>
);
}
export function paneContentToolbarIconSize(isCompact: boolean): number {
return iconButtonChromeGlyphSize("small", isCompact);
}
/** Keeps a toolbar glyph on the same trailing rail as tree-row glyphs. */
export function paneContentToolbarTrailingPadding(isCompact: boolean): number {
const buttonSize = smallIconButtonChromeFrameSize(isCompact);
return (
WORKSPACE_PANE_TRAILING_GLYPH_RAIL - (buttonSize - paneContentToolbarIconSize(isCompact)) / 2
);
}
/** @deprecated Use `ToolbarButton`; retained while remaining pane toolbars migrate. */
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,
iconButtonChromeStyle({
size: "small",
compact: isCompact,
state: { hovered, pressed, active: selected },
}),
];
}
@@ -43,23 +186,18 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
iconButton: {
width: 24,
height: 24,
controls: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
gap: 1,
},
iconButtonCompact: {
width: 32,
height: 32,
tooltipRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
gap: theme.spacing[2],
},
iconButtonSelected: {
backgroundColor: theme.colors.surface2,
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}));
@@ -0,0 +1,109 @@
import { forwardRef, useCallback, type ReactNode } from "react";
import {
Pressable,
Text,
View,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
export type ToolbarLabelTriggerState = PressableStateCallbackType & {
hovered?: boolean;
open?: boolean;
};
export function isToolbarLabelTriggerHighlighted(state: ToolbarLabelTriggerState): boolean {
return Boolean(state.hovered) || state.pressed || Boolean(state.open);
}
export function toolbarLabelTriggerStyle(state: ToolbarLabelTriggerState): StyleProp<ViewStyle> {
return [
toolbarLabelTriggerFrameStyle(),
isToolbarLabelTriggerHighlighted(state) ? styles.highlighted : null,
];
}
/** Shared spacing for interactive triggers and visually aligned passive context. */
export function toolbarLabelTriggerFrameStyle(): StyleProp<ViewStyle> {
return styles.trigger;
}
export function toolbarLabelTriggerTextStyle(highlighted: boolean): StyleProp<TextStyle> {
return [styles.label, highlighted ? styles.labelHighlighted : null];
}
/** Fixed geometry for glyphs beside labels. Text yields space; icons never do. */
export function ToolbarLabelTriggerIcon({ children }: { children: ReactNode }) {
return <View style={styles.icon}>{children}</View>;
}
interface ToolbarLabelSelectTriggerProps extends Omit<PressableProps, "children" | "style"> {
label: string;
open?: boolean;
}
const ThemedChevronDown = withUnistyles(ChevronDown);
/** The shared current/base branch geometry. Disabled keeps the frame but has no interaction. */
export const ToolbarLabelSelectTrigger = forwardRef<View, ToolbarLabelSelectTriggerProps>(
function ToolbarLabelSelectTrigger({ label, open = false, ...props }, ref) {
const triggerStyle = useCallback(
(state: ToolbarLabelTriggerState) => toolbarLabelTriggerStyle({ ...state, open }),
[open],
);
return (
<Pressable {...props} ref={ref} style={triggerStyle}>
{(state) => {
const highlighted = isToolbarLabelTriggerHighlighted({ ...state, open });
return (
<>
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
{label}
</Text>
<ToolbarLabelTriggerIcon>
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
</ToolbarLabelTriggerIcon>
</>
);
}}
</Pressable>
);
},
);
const styles = StyleSheet.create((theme) => ({
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
},
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
label: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelHighlighted: {
color: theme.colors.foreground,
},
icon: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -52,7 +52,8 @@ import {
buildWorkspaceTabPersistenceKey,
type WorkspaceDraftTabSetup,
} from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
import { useSettings } from "@/hooks/use-settings";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -439,6 +440,7 @@ export function WorkspaceDraftAgentTab({
workspaceId,
});
const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const attachmentScopeKeys = useMemo(
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
@@ -451,14 +453,15 @@ export function WorkspaceDraftAgentTab({
if (attachment.kind !== "review") {
return;
}
openSidePanelView({
openPreferredWorkspaceTarget({
isCompact: isCompactFormFactor,
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
view: "changes",
target: { kind: "working_diff" },
source: "changesLinks",
preferences: openInSidePane,
});
},
[isCompactFormFactor, serverId, workspaceId],
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
);
const {
@@ -37,7 +37,7 @@ function resolveChatTab(
/**
* 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 the user can see: standing in the Explorer sidebar looking at Changes, "add to
* chat" plainly means the chat in the pane next to it.
*/
export function resolveFocusedChatTarget(input: {
+2 -3
View File
@@ -27,9 +27,8 @@ export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Windows/Linux window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Custom desktop window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = HEADER_INNER_HEIGHT;
export {
getIsElectron as getIsElectronRuntime,
@@ -10,8 +10,8 @@ function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
target: { kind: "browser", browserId },
now: 1,
placement: FOCUSED_PANE_PLACEMENT,
sidePanelPaneId: null,
}).layout;
explorerSidebarPaneId: null,
})!.layout;
}
describe("browser new-tab requests", () => {
+3 -5
View File
@@ -4,7 +4,7 @@ import { Globe } from "lucide-react-native";
import invariant from "tiny-invariant";
import { BrowserPane } from "@/desktop/browser/pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
import { definePanel, type PanelDescriptor, type PanelIconProps } from "@/panels/panel-registry";
import { useBrowserStore } from "@/desktop/browser/store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
@@ -71,9 +71,7 @@ function BrowserPanel() {
);
}
export const browserPanelRegistration: PanelRegistration<"browser"> = {
kind: "browser",
resourceKey: (target) => target.browserId,
export const browserPanelRegistration = definePanel("browser", {
component: BrowserPanel,
useDescriptor: useBrowserPanelDescriptor,
};
});
+16 -6
View File
@@ -1,7 +1,7 @@
import {
getDesktopHost,
type DesktopWindowBridge,
type DesktopWindowControlsOverlayUpdate,
type DesktopWindowChromeUpdate,
} from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
@@ -16,6 +16,14 @@ export function getDesktopWindow(): DesktopWindowBridge | null {
}
}
export async function minimizeDesktopWindow(): Promise<void> {
await getDesktopWindow()?.minimize?.();
}
export async function closeDesktopWindow(): Promise<void> {
await getDesktopWindow()?.close?.();
}
export async function toggleDesktopMaximize(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.toggleMaximize !== "function") {
@@ -24,6 +32,10 @@ export async function toggleDesktopMaximize(): Promise<void> {
await win.toggleMaximize();
}
export async function isDesktopMaximized(): Promise<boolean> {
return (await getDesktopWindow()?.isMaximized?.()) ?? false;
}
export async function isDesktopFullscreen(): Promise<boolean> {
const win = getDesktopWindow();
if (!win || typeof win.isFullscreen !== "function") {
@@ -32,13 +44,11 @@ export async function isDesktopFullscreen(): Promise<boolean> {
return await win.isFullscreen();
}
export async function updateDesktopWindowControls(
update: DesktopWindowControlsOverlayUpdate,
): Promise<void> {
export async function updateDesktopWindowChrome(update: DesktopWindowChromeUpdate): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.updateWindowControls !== "function") {
if (!win || typeof win.updateChrome !== "function") {
return;
}
await win.updateWindowControls(update);
await win.updateChrome(update);
}
+20 -4
View File
@@ -13,6 +13,7 @@ type BrowserAutomationExecuteResponse = Extract<
>;
export type DesktopNotificationPermission = "granted" | "denied" | "default";
export type DesktopWindowChromeMode = "native-mac" | "custom-windows" | "custom-linux";
export interface DesktopDialogAskOptions {
title?: string;
@@ -92,19 +93,20 @@ export interface DesktopMenuBridge {
setCapturingShortcut?: (capturing: boolean) => Promise<void>;
}
export interface DesktopWindowControlsOverlayUpdate {
height?: number;
export interface DesktopWindowChromeUpdate {
backgroundColor?: string;
foregroundColor?: string;
trafficLightOffsetY?: number;
}
export interface DesktopWindowBridge {
label?: string;
minimize?: () => Promise<void>;
close?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isMaximized?: () => Promise<boolean>;
setFullscreen?: (fullscreen: boolean) => Promise<void>;
isFullscreen?: () => Promise<boolean>;
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
updateChrome?: (update: DesktopWindowChromeUpdate) => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -172,6 +174,7 @@ export interface DesktopInvokeBridge {
export interface DesktopHostBridge {
platform?: string;
windowChromeMode?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
agentNavigation?: DesktopAgentNavigationBridge;
@@ -217,3 +220,16 @@ export function isElectronRuntimeMac(): boolean {
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
}
export function getDesktopWindowChromeMode(): DesktopWindowChromeMode | null {
const host = getDesktopHost();
if (!host) return null;
const mode = host.windowChromeMode;
if (mode === "native-mac" || mode === "custom-windows" || mode === "custom-linux") {
return mode;
}
// COMPAT(windowChromeMode): added in v0.5.3; remove after 2026-11-25.
if (isElectronRuntimeMac()) return "native-mac";
if (host.platform?.toLowerCase() === "linux") return "custom-linux";
return "custom-windows";
}
@@ -0,0 +1,48 @@
import { DESKTOP_WINDOW_CONTROLS_HEIGHT } from "@/constants/layout";
import type { DesktopWindowChromeMode } from "@/desktop/host";
export type CustomDesktopWindowChromeMode = Extract<
DesktopWindowChromeMode,
"custom-windows" | "custom-linux"
>;
export interface DesktopWindowControlsPresentation {
controlWidth: number;
railHeight: number;
controlHeight: number;
glyphSize: number;
glyphStrokeWidth: number;
hoverShape: "control" | "circle";
}
const PRESENTATION_BY_MODE: Record<
CustomDesktopWindowChromeMode,
DesktopWindowControlsPresentation
> = {
"custom-windows": {
controlWidth: 46,
railHeight: DESKTOP_WINDOW_CONTROLS_HEIGHT,
controlHeight: DESKTOP_WINDOW_CONTROLS_HEIGHT - 1,
glyphSize: 16,
glyphStrokeWidth: 1,
hoverShape: "control",
},
"custom-linux": {
controlWidth: 36,
railHeight: DESKTOP_WINDOW_CONTROLS_HEIGHT,
controlHeight: DESKTOP_WINDOW_CONTROLS_HEIGHT - 1,
glyphSize: 14,
glyphStrokeWidth: 1.5,
hoverShape: "circle",
},
};
export function getDesktopWindowControlsPresentation(
mode: CustomDesktopWindowChromeMode,
): DesktopWindowControlsPresentation {
return PRESENTATION_BY_MODE[mode];
}
export function getDesktopWindowControlsWidth(mode: CustomDesktopWindowChromeMode): number {
return getDesktopWindowControlsPresentation(mode).controlWidth * 3;
}
-2
View File
@@ -7,7 +7,6 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
import { FileTreeToggle } from "./tree-toggle";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -109,7 +108,6 @@ export function FilePanelBar({
options={previewModes}
/>
) : null}
<FileTreeToggle />
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
+42 -14
View File
@@ -17,7 +17,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { GitAction, GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -152,7 +152,7 @@ export function GitActionsSplitButton({
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
<ChevronDown size={12} color={theme.colors.foregroundExtraMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{menuOnlyActions.map((action, index) => (
@@ -176,6 +176,10 @@ export function GitActionsSplitButton({
);
}
if (!gitActions.primary && gitActions.secondary.length === 0 && gitActions.menu.length === 0) {
return null;
}
return (
<View style={styles.row}>
{gitActions.primary ? (
@@ -213,7 +217,7 @@ export function GitActionsSplitButton({
accessibilityRole="button"
accessibilityLabel={t("workspace.git.actions.moreOptions")}
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
<ChevronDown size={16} color={theme.colors.foregroundExtraMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => (
@@ -272,27 +276,39 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
splitButton: {
height: buttonControlHeight.xs,
height: {
xs: buttonControlHeight.xs,
md: HEADER_CONTROL_HEIGHT,
},
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.lg,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingHorizontal: theme.spacing[3],
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
justifyContent: "center",
position: "relative",
},
menuOnlyTrigger: {
width: 48,
height: buttonControlHeight.xs,
width: {
xs: 48,
md: 42,
},
height: {
xs: buttonControlHeight.xs,
md: HEADER_CONTROL_HEIGHT,
},
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
@@ -309,24 +325,36 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
gap: {
xs: theme.spacing[2],
md: theme.spacing[1],
},
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 28,
width: {
xs: buttonControlHeight.xs,
md: HEADER_CONTROL_HEIGHT,
},
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
iconButton: {
width: buttonControlHeight.xs,
height: buttonControlHeight.xs,
width: {
xs: buttonControlHeight.xs,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: buttonControlHeight.xs,
md: HEADER_CONTROL_HEIGHT,
},
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
borderRadius: theme.borderRadius.md,
},
overflowMenuButton: {
marginRight: -theme.spacing[2],
@@ -37,6 +37,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
onActivate={activate}
onSelect={onSelectPath}
onOpenFile={working?.onOpenFile}
onOpenToSide={working?.onOpenToSide}
onAddToChat={working?.onAddToChat}
onCopyPath={working?.onCopyPath}
onCopyRelativePath={working?.onCopyRelativePath}
@@ -60,6 +61,7 @@ function documentFileHeaderPropsEqual(
previous.mode.onFilePress === next.mode.onFilePress &&
previous.mode.workspaceFileDragScope === next.mode.workspaceFileDragScope &&
previous.mode.onOpenFile === next.mode.onOpenFile &&
previous.mode.onOpenToSide === next.mode.onOpenToSide &&
previous.mode.onAddToChat === next.mode.onAddToChat &&
previous.mode.onCopyPath === next.mode.onCopyPath &&
previous.mode.onCopyRelativePath === next.mode.onCopyRelativePath &&
+6 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { withUnistyles } from "react-native-unistyles";
import { RenderProfile } from "@/utils/render-profiler";
import { createDiffPalette, retainDiffPalette } from "./palette";
import { DiffSurface } from "./surface";
import type { DiffDocumentProps, DiffPalette } from "./types";
@@ -47,5 +48,9 @@ const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
}));
export function DiffDocument(props: DiffDocumentProps) {
return <StyledDiffDocument {...props} />;
return (
<RenderProfile id="DiffDocument">
<StyledDiffDocument {...props} />
</RenderProfile>
);
}
@@ -8,12 +8,7 @@ import { DocumentFileHeader } from "./document-file-header";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import {
buildDiffDocumentModel,
FILE_HEADER_HEIGHT,
resolveRelayoutScrollTop,
retainReusableModels,
} from "./model";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
import { paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import { createMeasuredAdvances } from "./text-measurement";
@@ -25,22 +20,20 @@ import type {
DiffTypography,
TextMeasurer,
} from "./types";
import { useDiffDocumentWorkspaceCache } from "./workspace-cache";
const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
const RESIZE_SETTLE_DELAY_MS = 120;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
dependencies: readonly unknown[];
models: ReturnType<typeof buildDiffDocumentModel>[];
} | null>(null);
const consumedFocusRef = useRef<string | null>(null);
const scrollTopRef = useRef(0);
const horizontalOffsetsRef = useRef(new Map<string, number>());
@@ -60,7 +53,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
@@ -81,10 +73,28 @@ export function DiffSurface(props: DiffSurfaceProps) {
}),
[family, props.displayPreferences.codeFontSize],
);
const measurement = useMemo(
() => (loadedTypography ? createWebTextMeasurer(loadedTypography) : null),
[loadedTypography],
const typographyResource = useMemo(
() =>
workspaceCache.typography({
typography: desiredTypography,
load: async () => {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
]);
await document.fonts.ready;
},
createMeasurer: () => createWebTextMeasurer(desiredTypography),
}),
[desiredTypography, workspaceCache],
);
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
typeof workspaceCache.typography
> | null>(() => (typographyResource.isReady() ? typographyResource : null));
const loadedTypography =
readyTypographyResource === typographyResource ? typographyResource.typography : null;
const measurement =
readyTypographyResource === typographyResource ? typographyResource.measureText : null;
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
const model = useMemo(() => {
if (!loadedTypography || !measurement) {
@@ -95,22 +105,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
lineHeight: desiredTypography.lineHeight,
});
}
const dependencies = [
props.files,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
viewport.width,
loadedTypography,
measurement,
props.palette,
t,
] as const;
const previous = reusableModelRef.current;
const canReuse = previous?.dependencies.every(
(dependency, index) => dependency === dependencies[index],
);
const reuseFrom = canReuse ? previous?.models : undefined;
const next = buildDiffDocumentModel({
return workspaceCache.buildModel({
files: props.files,
collapsedFilePaths: props.collapsedFilePaths,
layout: props.displayPreferences.layout,
@@ -124,13 +119,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
reuseFrom,
});
reusableModelRef.current = {
dependencies,
models: retainReusableModels(reuseFrom, next),
};
return next;
}, [
measurement,
props.collapsedFilePaths,
@@ -143,6 +132,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
desiredTypography.lineHeight,
loadedTypography,
viewport.width,
workspaceCache,
]);
modelRef.current = model;
@@ -280,18 +270,19 @@ export function DiffSurface(props: DiffSurfaceProps) {
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
]);
await document.fonts.ready;
if (!cancelled) setLoadedTypography(desiredTypography);
})();
if (typographyResource.isReady()) {
setReadyTypographyResource(typographyResource);
return;
}
setReadyTypographyResource(null);
void typographyResource.load().then(() => {
if (!cancelled) setReadyTypographyResource(typographyResource);
return undefined;
});
return () => {
cancelled = true;
};
}, [desiredTypography]);
}, [typographyResource]);
useLayoutEffect(() => {
const previous = previousModelRef.current;
const scroll = scrollRef.current;
@@ -20,6 +20,7 @@ export interface WorkingDiffMode {
focusRequestId?: number;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
onOpenFile?: (path: string) => void;
onOpenToSide?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onCopyRelativePath?: (path: string) => void;
@@ -0,0 +1,173 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
const palette: DiffPalette = {
surface: "#000",
headerSurface: "#111",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
addition: "green",
deletion: "red",
additionBackground: "#010",
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
syntax: { keyword: "purple" },
};
function diffFile(): ParsedDiffFile {
return {
path: "src/a.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "const answer = 42;" }],
},
],
};
}
function countingMeasurer() {
const stats = { calls: 0 };
const measureText: TextMeasurer = {
measure(text) {
stats.calls += 1;
return text.length * 8;
},
};
return { measureText, stats };
}
function modelInput(
files: readonly ParsedDiffFile[],
measureText: TextMeasurer,
overrides: Partial<BuildDiffDocumentModelInput> = {},
): BuildDiffDocumentModelInput {
return {
files,
collapsedFilePaths: new Set(),
layout: "unified",
wrapLines: false,
viewportWidth: 800,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText,
palette,
labels: { binary: "Binary", tooLarge: "Too large" },
...overrides,
};
}
describe("diff document workspace cache", () => {
it("reuses measured rows until a model-building input changes", () => {
const cache = createDiffDocumentWorkspaceCache();
const files = [diffFile()];
const { measureText, stats } = countingMeasurer();
const input = modelInput(files, measureText);
const first = cache.buildModel(input);
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
const second = cache.buildModel(input);
expect(stats.calls).toBe(0);
expect(second).toBe(first);
const collapsed = cache.buildModel({
...input,
collapsedFilePaths: new Set(["src/a.ts"]),
});
expect(collapsed).not.toBe(first);
expect(collapsed.files[0]?.isCollapsed).toBe(true);
cache.buildModel({ ...input, viewportWidth: 640 });
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
cache.buildModel({
...input,
typography: { family: "monospace", size: 13, lineHeight: 20 },
});
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
cache.buildModel({ ...input, palette: { ...palette, foreground: "#eee" } });
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
cache.buildModel({ ...input, files: [...files] });
expect(stats.calls).toBeGreaterThan(0);
});
it("bounds retained geometry variants for one diff payload", () => {
const cache = createDiffDocumentWorkspaceCache();
const files = [diffFile()];
const { measureText, stats } = countingMeasurer();
for (const viewportWidth of [600, 700, 800, 900, 1000]) {
cache.buildModel(modelInput(files, measureText, { viewportWidth }));
}
stats.calls = 0;
cache.buildModel(modelInput(files, measureText, { viewportWidth: 600 }));
expect(stats.calls).toBeGreaterThan(0);
});
it("invalidates status rows when their translated labels change", () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
const files: ParsedDiffFile[] = [{ ...diffFile(), status: "binary", hunks: [] }];
const first = cache.buildModel(modelInput(files, measureText));
const second = cache.buildModel(
modelInput(files, measureText, {
labels: { binary: "Binary blob", tooLarge: "Too large" },
}),
);
expect(first.rows[0]).toMatchObject({ kind: "status", label: "Binary" });
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
});
it("shares loaded typography and its text measurer across mounts", async () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
let loadCount = 0;
let measurerCount = 0;
const typography = { family: "monospace", size: 12, lineHeight: 18 };
const resource = cache.typography({
typography,
load: async () => {
loadCount += 1;
},
createMeasurer: () => {
measurerCount += 1;
return measureText;
},
});
const reused = cache.typography({
typography: { ...typography },
load: async () => {
loadCount += 1;
},
createMeasurer: () => {
measurerCount += 1;
return measureText;
},
});
expect(reused).toBe(resource);
expect(measurerCount).toBe(1);
await Promise.all([resource.load(), reused.load()]);
expect(loadCount).toBe(1);
expect(resource.isReady()).toBe(true);
});
});
@@ -0,0 +1,168 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import invariant from "tiny-invariant";
import { buildDiffDocumentModel, retainReusableModels } from "./model";
import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
DiffPalette,
DiffTypography,
TextMeasurer,
} from "./types";
const MAX_MODEL_VARIANTS = 4;
const MAX_TYPOGRAPHY_RESOURCES = 4;
interface ModelVariant {
key: string;
exactKey: string;
model: DiffDocumentModel;
models: DiffDocumentModel[];
}
interface TypographyResourceInput {
typography: DiffTypography;
load: () => Promise<void>;
createMeasurer: () => TextMeasurer;
}
export interface DiffTypographyResource {
typography: DiffTypography;
measureText: TextMeasurer;
isReady(): boolean;
load(): Promise<DiffTypography>;
}
export interface DiffDocumentWorkspaceCache {
buildModel(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): DiffDocumentModel;
typography(input: TypographyResourceInput): DiffTypographyResource;
}
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], ModelVariant[]>();
const typographyResources = new Map<string, DiffTypographyResource>();
return {
buildModel(input) {
const key = modelVariantKey(input);
const exactKey = exactModelKey(input);
const variants = variantsByFiles.get(input.files) ?? [];
const variantIndex = variants.findIndex((candidate) => candidate.key === key);
const variant = variantIndex === -1 ? undefined : variants[variantIndex];
if (variant?.exactKey === exactKey) {
variants.splice(variantIndex, 1);
variants.unshift(variant);
return variant.model;
}
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
const nextVariant = {
key,
exactKey,
model,
models: retainReusableModels(variant?.models, model),
};
if (variantIndex !== -1) variants.splice(variantIndex, 1);
variants.unshift(nextVariant);
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
return model;
},
typography(input) {
const key = typographyKey(input.typography);
const existing = typographyResources.get(key);
if (existing) {
typographyResources.delete(key);
typographyResources.set(key, existing);
return existing;
}
const resource = createTypographyResource(input);
typographyResources.set(key, resource);
while (typographyResources.size > MAX_TYPOGRAPHY_RESOURCES) {
const oldestKey = typographyResources.keys().next().value;
if (oldestKey === undefined) break;
typographyResources.delete(oldestKey);
}
return resource;
},
};
}
function createTypographyResource(input: TypographyResourceInput): DiffTypographyResource {
let ready = false;
let pending: Promise<DiffTypography> | null = null;
return {
typography: input.typography,
measureText: input.createMeasurer(),
isReady: () => ready,
load() {
if (ready) return Promise.resolve(input.typography);
pending ??= input.load().then(() => {
ready = true;
return input.typography;
});
return pending;
},
};
}
function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
return JSON.stringify([
input.layout,
input.wrapLines,
input.viewportWidth,
typographyKey(input.typography),
paletteKey(input.palette),
input.labels.binary,
input.labels.tooLarge,
]);
}
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
const reviewGeometry = input.reviewActions
? {
comments: [...input.reviewActions.commentsByTarget.entries()]
.map(([target, comments]) => [target, comments.map((comment) => comment.id).sort()])
.sort(([left], [right]) => String(left).localeCompare(String(right))),
editor: input.reviewActions.editor
? [input.reviewActions.editor.target.key, input.reviewActions.editor.commentId]
: null,
}
: null;
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
}
function typographyKey(typography: DiffTypography): string {
return JSON.stringify([typography.family, typography.size, typography.lineHeight]);
}
function paletteKey(palette: DiffPalette): string {
const syntax = Object.keys(palette.syntax)
.sort()
.map((name) => [name, palette.syntax[name]]);
return JSON.stringify([
palette.surface,
palette.headerSurface,
palette.border,
palette.foreground,
palette.foregroundMuted,
palette.addition,
palette.deletion,
palette.additionBackground,
palette.deletionBackground,
palette.emptyBackground,
palette.selection,
syntax,
]);
}
const WorkspaceCacheContext = createContext<DiffDocumentWorkspaceCache | null>(null);
export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) {
const [cache] = useState(createDiffDocumentWorkspaceCache);
return <WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>;
}
export function useDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
const cache = useContext(WorkspaceCacheContext);
invariant(cache, "DiffDocumentWorkspaceCacheProvider is required");
return cache;
}
File diff suppressed because it is too large Load Diff
+15 -26
View File
@@ -9,7 +9,7 @@ import { MaterialFileIcon } from "@/components/material-file-icon";
import {
treeRowPaddingLeft,
workspaceTreeRowStyles,
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
WORKSPACE_TREE_ICON_LABEL_GAP,
WORKSPACE_TREE_ICON_SIZE,
@@ -32,6 +32,7 @@ export interface FileHeaderProps {
onActivate?: (path: string) => void;
onSelect?: (path: string) => void;
onOpenFile?: (path: string) => void;
onOpenToSide?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onCopyRelativePath?: (path: string) => void;
@@ -119,24 +120,10 @@ function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
return "modified";
}
function documentFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) {
if (!showsBodyState) return null;
if (file.isNew) return <FileChangeIcon change="added" />;
if (file.isDeleted) return <FileChangeIcon change="deleted" />;
return null;
}
function treeFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) {
return showsBodyState ? null : <FileChangeIcon change={fileChange(file)} />;
}
function fileHeaderRightStyle(showsBodyState: boolean) {
return showsBodyState ? styles.right : [styles.right, styles.treeRight];
}
function FileHeaderMenu({
file,
onOpenFile,
onOpenToSide,
onAddToChat,
onCopyPath,
onCopyRelativePath,
@@ -148,6 +135,7 @@ function FileHeaderMenu({
testID,
}: FileHeaderProps) {
const openFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]);
const openToSide = useCallback(() => onOpenToSide?.(file.path), [file.path, onOpenToSide]);
const addToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]);
const copyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]);
const copyRelativePath = useCallback(
@@ -166,6 +154,7 @@ function FileHeaderMenu({
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? openFile : undefined}
onOpenToSide={onOpenToSide ? openToSide : undefined}
onCopyPath={onCopyPath ? copyPath : undefined}
onCopyRelativePath={onCopyRelativePath ? copyRelativePath : undefined}
onReveal={onReveal ? reveal : undefined}
@@ -238,9 +227,7 @@ export const FileHeader = memo(function FileHeader({
);
const fileName = fileNameForPath(file.path);
const nameStyle = fileHeaderNameStyle(showsBodyState, hover.isHovered);
const documentChangeIcon = documentFileChangeIcon(file, showsBodyState);
const treeChangeIcon = treeFileChangeIcon(file, showsBodyState);
const rightStyle = fileHeaderRightStyle(showsBodyState);
const changeIcon = <FileChangeIcon change={fileChange(file)} />;
const content = (
<View
style={[styles.content, showsBodyState && styles.documentContent]}
@@ -262,15 +249,14 @@ export const FileHeader = memo(function FileHeader({
) : (
<View style={styles.directorySpacer} />
)}
{documentChangeIcon}
</View>
<View style={rightStyle}>
<View style={styles.right}>
<DiffStat
additions={file.additions}
deletions={file.deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
{treeChangeIcon}
{changeIcon}
</View>
</View>
);
@@ -332,7 +318,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
paddingLeft: theme.spacing[3],
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
paddingRight: WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1],
minWidth: 0,
@@ -354,7 +340,11 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
userSelect: "none",
},
documentContent: { height: 28, paddingHorizontal: theme.spacing[3] },
documentContent: {
height: 28,
paddingLeft: theme.spacing[3],
paddingRight: WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
},
documentActive: { backgroundColor: theme.colors.surface1 },
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
left: {
@@ -369,11 +359,10 @@ const styles = StyleSheet.create((theme) => ({
right: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
gap: theme.spacing[2],
flexShrink: 0,
userSelect: "none",
},
treeRight: { gap: theme.spacing[2] },
icon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,

Some files were not shown because too many files have changed in this diff Show More