mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 14:22:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da9fa43fa | ||
|
|
cd8c868b32 | ||
|
|
6f6466d4cb | ||
|
|
6147991519 | ||
|
|
bbe3f17327 | ||
|
|
c914bcb44b | ||
|
|
54299a2e6c | ||
|
|
f4b5f32862 | ||
|
|
7db654aa0a | ||
|
|
4092c29265 | ||
|
|
5cedc210a8 | ||
|
|
4724d1d9b7 | ||
|
|
8fdca94ead | ||
|
|
88f410567d | ||
|
|
f998ee1691 | ||
|
|
7a47ec0ee9 | ||
|
|
4f796181bf |
@@ -1,5 +1,32 @@
|
||||
# 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
|
||||
|
||||
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||
|
||||
## 0.5.1 - 2026-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -1 +1 @@
|
||||
sha256-mF8N1sBkSt2/ZgtB511lqv1knlQ0lmicZxsoucbWZfE=
|
||||
sha256-TRZej2L43C3go4NWe496Dqs/4A+0GivCRtGzt3pX2dw=
|
||||
|
||||
Generated
+25
-25
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.5.1",
|
||||
"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.1",
|
||||
"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.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/server": "0.5.1",
|
||||
"@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.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"@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.1",
|
||||
"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.1",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38597,7 +38597,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.5.1",
|
||||
"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.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@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.1",
|
||||
"@getpaseo/client": "0.6.0",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38660,7 +38660,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.5.1",
|
||||
"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.1",
|
||||
"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.1",
|
||||
"@getpaseo/highlight": "0.5.1",
|
||||
"@getpaseo/plugin": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"@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.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.1",
|
||||
"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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.1",
|
||||
"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),
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+944
-377
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user