mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9400a49af6 | ||
|
|
e9612df862 | ||
|
|
8dd4da6ee0 | ||
|
|
3998ea11d9 | ||
|
|
ba54575ab7 | ||
|
|
3e59adb4dc | ||
|
|
640b456915 | ||
|
|
dd5e8f95b1 |
@@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.2 - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174))
|
||||||
|
- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190))
|
||||||
|
- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166))
|
||||||
|
- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119))
|
||||||
|
- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161))
|
||||||
|
- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168))
|
||||||
|
- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g))
|
||||||
|
- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170))
|
||||||
|
- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171))
|
||||||
|
|
||||||
|
## 0.7.1 - 2026-09-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
|
||||||
|
|
||||||
## 0.7.0 - 2026-08-31
|
## 0.7.0 - 2026-08-31
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
|||||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||||
|
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
||||||
|
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
||||||
|
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||||||
|
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||||
|
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||||
|
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
|
||||||
|
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||||
|
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||||||
|
silently stops.
|
||||||
|
|
||||||
## Measuring
|
## Measuring
|
||||||
|
|
||||||
|
|||||||
+21
-10
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
|
|||||||
- the active gesture's starting revision
|
- the active gesture's starting revision
|
||||||
- the last settled target
|
- the last settled target
|
||||||
|
|
||||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||||
|
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||||
|
React owns pointer events and accessibility.
|
||||||
|
|
||||||
## Why one position
|
## Why one position
|
||||||
|
|
||||||
@@ -55,8 +57,10 @@ while that revision still owns the gesture.
|
|||||||
|
|
||||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||||
its target and revision still match the canonical command.
|
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
||||||
|
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
||||||
|
the same target.
|
||||||
|
|
||||||
Manual gesture arbitration has two phases:
|
Manual gesture arbitration has two phases:
|
||||||
|
|
||||||
@@ -85,8 +89,14 @@ definition, no longer eligible to begin.
|
|||||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||||
has caused native crashes.
|
has caused native crashes.
|
||||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
|
||||||
prop commit from resurrecting a closed overlay.
|
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
|
||||||
|
animated props back to React through a collector that forgets entries older than two seconds. A JS
|
||||||
|
stall across a panel settle loses the final position, and the next React commit reverts the overlay
|
||||||
|
to its last synced props: a backdrop over the workspace while the store says closed. Upstream fixed
|
||||||
|
the collector in 4.4.3, which needs React Native 0.83.
|
||||||
|
- Gesture start and progress must not update React state. The retained overlay is already mounted and
|
||||||
|
offscreen; only the shared position and its derived native styles move during a drag.
|
||||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||||
@@ -98,8 +108,9 @@ definition, no longer eligible to begin.
|
|||||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
|
||||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
canonical target's anchor is the settlement signal; animation duration and completion callbacks
|
||||||
|
do not own activity. Cancellation publishes nothing.
|
||||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||||
@@ -107,5 +118,5 @@ definition, no longer eligible to begin.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
|
||||||
sequence there whenever ownership or ordering changes.
|
a sequence there whenever ownership or ordering changes.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Fable 5.1 with a 1M context window
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Fable 5.1 with a 1M context window
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Fable 5.1 with a 1M context window
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Fable 5.1 with a 1M context window
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Added
|
||||||
|
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||||
|
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Reduced JS stalls while watching a streaming agent on mobile
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Added
|
||||||
|
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||||
|
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Reduced JS stalls while watching a streaming agent on mobile
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Added
|
||||||
|
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||||
|
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Reduced JS stalls while watching a streaming agent on mobile
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Added
|
||||||
|
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||||
|
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Reduced JS stalls while watching a streaming agent on mobile
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-HFYBrCP62r6ZofazBIGSOnMhk8IIEmv+TFJMpqTIJQ8=
|
sha256-j3lk7o4W10hcXeNIN3kGlqSHn8aCr+GCDkKYZNXNSrw=
|
||||||
|
|||||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36298,7 +36298,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37363,12 +37363,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@getpaseo/server": "0.7.0",
|
"@getpaseo/server": "0.7.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37615,10 +37615,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@getpaseo/relay": "0.7.0",
|
"@getpaseo/relay": "0.7.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37629,7 +37629,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37872,7 +37872,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38768,7 +38768,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38797,13 +38797,13 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38812,15 +38812,15 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38833,7 +38833,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39051,17 +39051,17 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/highlight": "0.7.0",
|
"@getpaseo/highlight": "0.7.2",
|
||||||
"@getpaseo/plugin": "0.7.0",
|
"@getpaseo/plugin": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@getpaseo/relay": "0.7.0",
|
"@getpaseo/relay": "0.7.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -39985,7 +39985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function changesTree(page: Page) {
|
|||||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function diffHeaderForPath(panel: Locator, filePath: string): Locator {
|
||||||
|
return panel.locator(`[data-diff-header-path="${filePath}"]`).getByTestId(/^diff-file-\d+$/);
|
||||||
|
}
|
||||||
|
|
||||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return await readFile(filePath, "utf8");
|
return await readFile(filePath, "utf8");
|
||||||
@@ -261,12 +265,12 @@ test.afterEach(async () => {
|
|||||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithCommittedDiff();
|
const workspace = await createWorkspaceWithCommittedDiff();
|
||||||
|
|
||||||
await openWorkspaceChangesSurface(page, workspace);
|
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||||
@@ -302,7 +306,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-committed").click();
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||||
|
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||||
@@ -312,7 +316,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
await expect(seeCommitted).toBeVisible();
|
await expect(seeCommitted).toBeVisible();
|
||||||
await seeCommitted.click();
|
await seeCommitted.click();
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||||
@@ -322,9 +326,8 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
const deletedFileName = page.getByTestId("diff-file-1");
|
||||||
const deletedFileName = page.getByTestId("diff-file-1-name");
|
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
|
||||||
await deletedFileName.dblclick();
|
await deletedFileName.dblclick();
|
||||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||||
@@ -378,49 +381,109 @@ test("every interactive file header has the same hover feedback", async ({ page
|
|||||||
|
|
||||||
const first = page.getByTestId("diff-file-0-toggle");
|
const first = page.getByTestId("diff-file-0-toggle");
|
||||||
const second = page.getByTestId("diff-file-1-toggle");
|
const second = page.getByTestId("diff-file-1-toggle");
|
||||||
const normalBackground = await first.evaluate(
|
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||||
(element) => getComputedStyle(element.parentElement!).backgroundColor,
|
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||||
);
|
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
|
||||||
)
|
|
||||||
.toBe("0px");
|
|
||||||
|
|
||||||
await first.hover();
|
await first.hover();
|
||||||
const hoverBackground = await first.evaluate(
|
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||||
);
|
|
||||||
expect(hoverBackground).not.toBe(normalBackground);
|
|
||||||
|
|
||||||
await first.click();
|
await first.click();
|
||||||
await page.mouse.move(0, 0);
|
await page.mouse.move(0, 0);
|
||||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||||
await expect
|
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||||
.poll(() =>
|
|
||||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
|
||||||
)
|
|
||||||
.toBe(normalBackground);
|
|
||||||
const [sharedBorder, secondTopBorderWidth] = await Promise.all([
|
|
||||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderBottomColor),
|
|
||||||
second.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
|
||||||
]);
|
|
||||||
expect(sharedBorder).not.toBe("rgba(0, 0, 0, 0)");
|
|
||||||
expect(secondTopBorderWidth).toBe("0px");
|
|
||||||
|
|
||||||
await first.hover();
|
await first.hover();
|
||||||
await expect
|
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||||
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
||||||
.toBe(hoverBackground);
|
|
||||||
await second.hover();
|
await second.hover();
|
||||||
|
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||||
|
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||||
|
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
|
||||||
|
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const headerCanvas = page.getByTestId("git-diff-header-canvas");
|
||||||
|
const header = page.getByTestId("diff-file-0-toggle");
|
||||||
|
const beforeImage = await headerCanvas.evaluate((element) =>
|
||||||
|
(element as HTMLCanvasElement).toDataURL(),
|
||||||
|
);
|
||||||
|
const beforeBounds = await header.boundingBox();
|
||||||
|
|
||||||
|
await horizontallyScrollFirstFile(page, 320);
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
|
||||||
|
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
|
||||||
|
beforeImage,
|
||||||
|
);
|
||||||
|
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
|
const scroller = page.getByTestId("git-diff-scroll");
|
||||||
|
const first = page.getByTestId("diff-file-0-toggle");
|
||||||
|
const second = page.getByTestId("diff-file-1-toggle");
|
||||||
|
const secondTop = await second.evaluate((element) => {
|
||||||
|
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||||
|
if (!scrollElement) return Number.NaN;
|
||||||
|
return (
|
||||||
|
scrollElement.scrollTop +
|
||||||
|
element.getBoundingClientRect().top -
|
||||||
|
scrollElement.getBoundingClientRect().top
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(secondTop).toBeGreaterThan(30);
|
||||||
|
await scroller.evaluate((element, scrollTop) => {
|
||||||
|
element.scrollTop = scrollTop;
|
||||||
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
}, secondTop - 15);
|
||||||
|
await expect(first).toBeAttached();
|
||||||
|
await expect(second).toBeAttached();
|
||||||
|
|
||||||
|
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||||
|
scroller.boundingBox(),
|
||||||
|
first.boundingBox(),
|
||||||
|
second.boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||||
|
throw new Error("Sticky transition geometry is unavailable");
|
||||||
|
}
|
||||||
|
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||||
|
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||||
|
const transitionHits = await scroller.evaluate((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return [14, 16].map(
|
||||||
|
(y) =>
|
||||||
|
document
|
||||||
|
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
|
||||||
|
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||||
|
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||||
|
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
await scroller.evaluate((element, scrollTop) => {
|
||||||
|
element.scrollTop = scrollTop;
|
||||||
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
}, secondTop);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(async () => {
|
||||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
const bounds = await second.boundingBox();
|
||||||
)
|
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||||
.toBe(normalBackground);
|
})
|
||||||
await expect
|
.toBeCloseTo(0, 0);
|
||||||
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
||||||
.toBe(hoverBackground);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||||
@@ -475,7 +538,8 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
|||||||
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||||
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||||
|
|
||||||
// Directories sort before files at every level, so the root note lands last
|
// Directories sort before files at every level, so the root note lands last
|
||||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||||
@@ -485,9 +549,17 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
|||||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||||
// of the diff rather than a match, and mask a real ordering difference.
|
// of the diff rather than a match, and mask a real ordering difference.
|
||||||
await expect(treeNames).toHaveCount(expected.length);
|
await expect(treeNames).toHaveCount(expected.length);
|
||||||
await expect(diffNames).toHaveCount(expected.length);
|
await expect(diffHeaders).toHaveCount(expected.length);
|
||||||
await expect(treeNames).toHaveText(expected);
|
await expect(treeNames).toHaveText(expected);
|
||||||
await expect(diffNames).toHaveText(expected);
|
const expectedHeaderNames = [
|
||||||
|
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||||
|
"src/zz-folder/root.ts, +1, -1",
|
||||||
|
"src/use-mounted-tab-set.ts, +37, -1",
|
||||||
|
"a-root-note.txt, +1, -0",
|
||||||
|
];
|
||||||
|
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||||
|
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||||
@@ -586,9 +658,10 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const renamedToggle = page
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||||
.filter({ hasText: "zz-renamed.ts" });
|
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
||||||
|
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||||
expect(toggleTestId).not.toBeNull();
|
expect(toggleTestId).not.toBeNull();
|
||||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||||
@@ -615,9 +688,10 @@ test("discarding an untracked file removes it from the working tree", async ({ p
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const untrackedToggle = page
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
|
||||||
.filter({ hasText: "zz-untracked.txt" });
|
await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0");
|
||||||
|
const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||||
expect(toggleTestId).not.toBeNull();
|
expect(toggleTestId).not.toBeNull();
|
||||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||||
@@ -676,8 +750,12 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).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-selected-diff-stat")).toHaveCount(0);
|
||||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
"src/use-mounted-tab-set.ts, +37, -1",
|
||||||
|
);
|
||||||
|
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||||
|
"src/zz-deleted.ts, +0, -1",
|
||||||
|
);
|
||||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||||
@@ -712,16 +790,18 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
await expect(
|
||||||
timeout: 30_000,
|
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||||
});
|
).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||||
timeout: 30_000,
|
"src/use-mounted-tab-set.ts, +37, -1",
|
||||||
});
|
{ timeout: 30_000 },
|
||||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
);
|
||||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||||
|
"src/zz-deleted.ts, +0, -1",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||||
@@ -980,6 +1060,29 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
|||||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||||
|
|
||||||
|
const root = page.getByTestId("git-diff-canvas-root");
|
||||||
|
const scroller = page.getByTestId("git-diff-scroll");
|
||||||
|
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||||
|
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||||
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
|
||||||
|
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||||
|
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1380,6 +1483,58 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
|||||||
return { id: created.workspace.id, repoPath: repo.path };
|
return { id: created.workspace.id, repoPath: repo.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||||
|
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||||
|
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||||
|
content: "export const value = 0;\n",
|
||||||
|
}));
|
||||||
|
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file, index) =>
|
||||||
|
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
cleanupTasks.push({
|
||||||
|
run: async () => {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||||
|
if (!created.workspace) throw new Error(created.error ?? "Failed to create many-file workspace");
|
||||||
|
return { id: created.workspace.id, repoPath: repo.path };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace> {
|
||||||
|
const source = Array.from(
|
||||||
|
{ length: 48 },
|
||||||
|
(_, index) => `export const value${index} = ${index};`,
|
||||||
|
).join("\n");
|
||||||
|
const files = ["src/first.ts", "src/second.ts"].map((filePath) => ({
|
||||||
|
path: filePath,
|
||||||
|
content: `${source}\n`,
|
||||||
|
}));
|
||||||
|
const repo = await createTempGitRepo("changes-canvas-sticky-transition-", { files });
|
||||||
|
const changedSource = Array.from(
|
||||||
|
{ length: 48 },
|
||||||
|
(_, index) => `export const value${index} = ${index + 100};`,
|
||||||
|
).join("\n");
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file) => writeFile(path.join(repo.path, file.path), `${changedSource}\n`)),
|
||||||
|
);
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
cleanupTasks.push({
|
||||||
|
run: async () => {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||||
|
if (!created.workspace) throw new Error(created.error ?? "Failed to create sticky workspace");
|
||||||
|
return { id: created.workspace.id, repoPath: repo.path };
|
||||||
|
}
|
||||||
|
|
||||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
@@ -1389,11 +1544,15 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
|||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openWorkspaceChangesSurface(
|
||||||
|
page: Page,
|
||||||
|
workspace: DirtyWorkspace,
|
||||||
|
timeout?: number,
|
||||||
|
): Promise<void> {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
@@ -1402,7 +1561,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
|||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
@@ -1701,6 +1860,30 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
|||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function headerCanvasPixel(
|
||||||
|
canvas: Locator,
|
||||||
|
header: Locator,
|
||||||
|
offsetY: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const [canvasBounds, headerBounds] = await Promise.all([
|
||||||
|
canvas.boundingBox(),
|
||||||
|
header.boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!canvasBounds || !headerBounds) throw new Error("Canvas header has no bounds");
|
||||||
|
return canvas.evaluate(
|
||||||
|
(element, point) => {
|
||||||
|
const target = element as HTMLCanvasElement;
|
||||||
|
const ratio = target.width / target.getBoundingClientRect().width;
|
||||||
|
const context = target.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas header has no 2D context");
|
||||||
|
return Array.from(
|
||||||
|
context.getImageData(Math.round(point.x * ratio), Math.round(point.y * ratio), 1, 1).data,
|
||||||
|
).join(",");
|
||||||
|
},
|
||||||
|
{ x: 4, y: headerBounds.y - canvasBounds.y + offsetY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function readDiffTypographyGeometry(page: Page): Promise<{
|
async function readDiffTypographyGeometry(page: Page): Promise<{
|
||||||
horizontalExtent: number;
|
horizontalExtent: number;
|
||||||
canvasPixels: string;
|
canvasPixels: string;
|
||||||
|
|||||||
@@ -103,23 +103,15 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||||
const [header, content, name, stat] = await Promise.all([
|
const [header, canvas] = await Promise.all([
|
||||||
panel.getByTestId("diff-file-0").boundingBox(),
|
panel.getByTestId("diff-file-0").boundingBox(),
|
||||||
panel.getByTestId("diff-file-0-header-content").boundingBox(),
|
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||||
panel.getByTestId("diff-file-0-name").boundingBox(),
|
|
||||||
panel.getByTestId("diff-file-0-stat").boundingBox(),
|
|
||||||
]);
|
]);
|
||||||
expect(header).not.toBeNull();
|
expect(header).not.toBeNull();
|
||||||
expect(content).not.toBeNull();
|
expect(canvas).not.toBeNull();
|
||||||
expect(name).not.toBeNull();
|
|
||||||
expect(stat).not.toBeNull();
|
|
||||||
expect(header!.height).toBeCloseTo(30, 0);
|
expect(header!.height).toBeCloseTo(30, 0);
|
||||||
expect(content!.x).toBeCloseTo(header!.x, 0);
|
expect(header!.x).toBeCloseTo(canvas!.x, 0);
|
||||||
expect(content!.width).toBeCloseTo(header!.width, 0);
|
expect(header!.width).toBeCloseTo(canvas!.width, 0);
|
||||||
expect(name!.x - header!.x).toBeCloseTo(12, 0);
|
expect(header!.y).toBeCloseTo(canvas!.y, 0);
|
||||||
expect(name!.x + name!.width).toBeLessThan(stat!.x);
|
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
|
||||||
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
|
|||||||
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
||||||
|
|
||||||
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
||||||
|
const RUN_HEADER_COMPARISON = process.env.PASEO_DIFF_HEADER_COMPARISON === "1";
|
||||||
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
||||||
const LINE_COUNT_PER_FILE = 1_200;
|
const LINE_COUNT_PER_FILE = 1_200;
|
||||||
|
const MANY_FILE_COUNT = Number(process.env.PASEO_DIFF_MANY_FILE_COUNT ?? 2_000);
|
||||||
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
|
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
|
||||||
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
||||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||||
@@ -21,8 +23,10 @@ const POC_MEDIAN_FRAME_MS = Number(
|
|||||||
const POC_P95_FRAME_MS = Number(
|
const POC_P95_FRAME_MS = Number(
|
||||||
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
||||||
);
|
);
|
||||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
|
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
|
||||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 2.4;
|
||||||
|
const MANY_FILE_MAX_MEDIAN_FRAME_MS = 45;
|
||||||
|
const MANY_FILE_MAX_P95_FRAME_MS = 80;
|
||||||
|
|
||||||
diffPerfDescribe("Diff canvas performance", () => {
|
diffPerfDescribe("Diff canvas performance", () => {
|
||||||
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
||||||
@@ -58,7 +62,131 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
test("many small files keep canvas headers bounded and responsive", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
const repo = await createManyFileDiffRepository(MANY_FILE_COUNT);
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
let cdp: CDPSession | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: repo.path },
|
||||||
|
});
|
||||||
|
if (!created.workspace) {
|
||||||
|
throw new Error(created.error ?? "Failed to create many-file performance workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
await configureUnwrappedUnifiedDiff(page);
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
const openedAt = performance.now();
|
||||||
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await openChangesPanel(page, 90_000);
|
||||||
|
await expect(page.getByTestId("diff-file-0-toggle")).toBeVisible({ timeout: 30_000 });
|
||||||
|
const readyMs = performance.now() - openedAt;
|
||||||
|
const [initialShells, initialElements, paintKind] = await page
|
||||||
|
.getByTestId("git-diff-canvas-root")
|
||||||
|
.evaluate(
|
||||||
|
(root) =>
|
||||||
|
[
|
||||||
|
root.querySelectorAll('[data-diff-header="true"]').length,
|
||||||
|
root.querySelectorAll("*").length,
|
||||||
|
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
|
||||||
|
] as const,
|
||||||
|
);
|
||||||
|
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
|
||||||
|
const style = getComputedStyle(header);
|
||||||
|
const bounds = header.getBoundingClientRect();
|
||||||
|
const leaves = [];
|
||||||
|
for (const element of header.querySelectorAll<HTMLElement>("*")) {
|
||||||
|
if (element.children.length !== 0 || !element.textContent?.trim()) continue;
|
||||||
|
const leafStyle = getComputedStyle(element);
|
||||||
|
const leafBounds = element.getBoundingClientRect();
|
||||||
|
leaves.push({
|
||||||
|
text: element.textContent,
|
||||||
|
x: leafBounds.x - bounds.x,
|
||||||
|
y: leafBounds.y - bounds.y,
|
||||||
|
width: leafBounds.width,
|
||||||
|
height: leafBounds.height,
|
||||||
|
font: leafStyle.font,
|
||||||
|
lineHeight: leafStyle.lineHeight,
|
||||||
|
color: leafStyle.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
padding: style.padding,
|
||||||
|
border: style.border,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
leaves,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const headerCanvasMetrics = await page
|
||||||
|
.getByTestId("git-diff-header-canvas")
|
||||||
|
.evaluate((canvas: HTMLCanvasElement) => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Header canvas has no 2D context");
|
||||||
|
const font = context.font;
|
||||||
|
return {
|
||||||
|
font,
|
||||||
|
nameWidth: context.measureText("file-0000.ts").width,
|
||||||
|
directoryWidth: context.measureText(" src").width,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
const capturePath = process.env.PASEO_DIFF_HEADER_CAPTURE_PATH;
|
||||||
|
if (capturePath) {
|
||||||
|
await writeFile(capturePath, await page.getByTestId("git-diff-canvas-root").screenshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||||
|
await startRendererInstrumentation(page);
|
||||||
|
const benchmark = await benchmarkManyFileHeaderScrolling(page);
|
||||||
|
const renderer = await readRendererInstrumentation(page);
|
||||||
|
const heap = await collectHeapUsage(cdp);
|
||||||
|
|
||||||
|
await attachPerformanceReport(testInfo, {
|
||||||
|
scenario: "many-small-files",
|
||||||
|
sourceFiles: MANY_FILE_COUNT,
|
||||||
|
cpuSlowdown: CPU_SLOWDOWN,
|
||||||
|
readyMs,
|
||||||
|
initialShells,
|
||||||
|
initialElements,
|
||||||
|
paintKind,
|
||||||
|
headerDomMetrics,
|
||||||
|
headerCanvasMetrics,
|
||||||
|
benchmark,
|
||||||
|
renderer,
|
||||||
|
heap,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[perf] Many-file headers: ${JSON.stringify({ readyMs, initialShells, initialElements, paintKind, headerDomMetrics, headerCanvasMetrics, benchmark, renderer, heap })}`,
|
||||||
|
);
|
||||||
|
if (!RUN_HEADER_COMPARISON) {
|
||||||
|
expect(benchmark.maximumShells).toBeLessThan(120);
|
||||||
|
expect(benchmark.blankHeaderFrames).toBe(0);
|
||||||
|
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
|
||||||
|
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
|
||||||
|
expect(Math.abs(benchmark.maximumScrollTop - benchmark.endScrollTop)).toBeLessThanOrEqual(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(renderer.reactCommits).toBeLessThanOrEqual(80);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
||||||
|
await cdp?.detach().catch(() => undefined);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bounded canvases stay filled through continuous fast scrolling", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
const repo = await createLargeDiffRepository();
|
const repo = await createLargeDiffRepository();
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
@@ -80,7 +208,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expectSingleCanvasSurface(page);
|
await expectBoundedCanvasSurfaces(page);
|
||||||
cdp = await page.context().newCDPSession(page);
|
cdp = await page.context().newCDPSession(page);
|
||||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||||
await startChromiumTrace(cdp);
|
await startChromiumTrace(cdp);
|
||||||
@@ -110,6 +238,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
||||||
const renderer = await readRendererInstrumentation(page);
|
const renderer = await readRendererInstrumentation(page);
|
||||||
const measuredCycles = scrollReports.slice(1);
|
const measuredCycles = scrollReports.slice(1);
|
||||||
|
console.log(
|
||||||
|
`[perf] Large-file canvas: ${JSON.stringify({ scrollReports, heapUsage, renderer })}`,
|
||||||
|
);
|
||||||
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
||||||
MAX_MEDIAN_FRAME_MS,
|
MAX_MEDIAN_FRAME_MS,
|
||||||
);
|
);
|
||||||
@@ -122,9 +253,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
||||||
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
||||||
);
|
);
|
||||||
expect(renderer.addedElements).toBe(0);
|
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||||
expect(renderer.removedElements).toBe(0);
|
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||||
|
|
||||||
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||||
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
||||||
@@ -169,7 +300,7 @@ async function measureAdditionalPerformanceScenario(
|
|||||||
testInfo: TestInfo,
|
testInfo: TestInfo,
|
||||||
name: "wrapped" | "split",
|
name: "wrapped" | "split",
|
||||||
) {
|
) {
|
||||||
await expectSingleCanvasSurface(page);
|
await expectBoundedCanvasSurfaces(page);
|
||||||
await startRendererInstrumentation(page);
|
await startRendererInstrumentation(page);
|
||||||
const scrollReports = [];
|
const scrollReports = [];
|
||||||
const heapUsage = [];
|
const heapUsage = [];
|
||||||
@@ -202,9 +333,9 @@ async function measureAdditionalPerformanceScenario(
|
|||||||
}
|
}
|
||||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
||||||
const renderer = await readRendererInstrumentation(page);
|
const renderer = await readRendererInstrumentation(page);
|
||||||
expect(renderer.addedElements).toBe(0);
|
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||||
expect(renderer.removedElements).toBe(0);
|
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||||
return {
|
return {
|
||||||
scrollReports,
|
scrollReports,
|
||||||
heapUsage,
|
heapUsage,
|
||||||
@@ -251,6 +382,20 @@ async function createLargeDiffRepository() {
|
|||||||
return repo;
|
return repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createManyFileDiffRepository(fileCount: number) {
|
||||||
|
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||||
|
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||||
|
content: "export const value = 0;\n",
|
||||||
|
}));
|
||||||
|
const repo = await createTempGitRepo("diff-perf-many-files-", { files });
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file, index) =>
|
||||||
|
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ preferencesKey }) => {
|
({ preferencesKey }) => {
|
||||||
@@ -289,8 +434,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
|||||||
return performance.now() - startedAt;
|
return performance.now() - startedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||||
await expect(page.locator("canvas")).toHaveCount(1);
|
await expect(page.locator("canvas")).toHaveCount(2);
|
||||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||||
const elementCount = await page
|
const elementCount = await page
|
||||||
.getByTestId("git-diff-canvas-root")
|
.getByTestId("git-diff-canvas-root")
|
||||||
@@ -377,6 +522,92 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||||
|
medianFrameMs: number;
|
||||||
|
p95FrameMs: number;
|
||||||
|
maximumFrameMs: number;
|
||||||
|
maximumShells: number;
|
||||||
|
blankHeaderFrames: number;
|
||||||
|
endScrollTop: number;
|
||||||
|
maximumScrollTop: number;
|
||||||
|
}> {
|
||||||
|
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||||
|
const scroll = element as HTMLElement;
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||||
|
'[data-testid="git-diff-header-canvas"]',
|
||||||
|
);
|
||||||
|
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||||
|
const frameDurations: number[] = [];
|
||||||
|
let maximumShells = 0;
|
||||||
|
let blankHeaderFrames = 0;
|
||||||
|
const sampleHeader = () => {
|
||||||
|
maximumShells = Math.max(
|
||||||
|
maximumShells,
|
||||||
|
document.querySelectorAll('[data-diff-header="true"]').length,
|
||||||
|
);
|
||||||
|
if (canvas) {
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
const ratio = canvas.width / canvas.getBoundingClientRect().width;
|
||||||
|
if (
|
||||||
|
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
|
||||||
|
) {
|
||||||
|
blankHeaderFrames += 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = scroll.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
|
||||||
|
) {
|
||||||
|
blankHeaderFrames += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
|
||||||
|
const sweepStart = Math.min(
|
||||||
|
maximumScrollTop,
|
||||||
|
Math.max(0, maximumScrollTop * progress - scroll.clientHeight),
|
||||||
|
);
|
||||||
|
scroll.scrollTop = sweepStart;
|
||||||
|
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
);
|
||||||
|
let previousFrame = performance.now();
|
||||||
|
for (let step = 1; step <= 24; step += 1) {
|
||||||
|
scroll.scrollTop = Math.min(
|
||||||
|
maximumScrollTop,
|
||||||
|
sweepStart + (scroll.clientHeight * step) / 24,
|
||||||
|
);
|
||||||
|
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
const now = performance.now();
|
||||||
|
frameDurations.push(now - previousFrame);
|
||||||
|
previousFrame = now;
|
||||||
|
sampleHeader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll.scrollTop = maximumScrollTop;
|
||||||
|
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
sampleHeader();
|
||||||
|
|
||||||
|
const ordered = [...frameDurations].sort((left, right) => left - right);
|
||||||
|
const percentile = (fraction: number) =>
|
||||||
|
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
|
||||||
|
return {
|
||||||
|
medianFrameMs: percentile(0.5),
|
||||||
|
p95FrameMs: percentile(0.95),
|
||||||
|
maximumFrameMs: Math.max(...frameDurations),
|
||||||
|
maximumShells,
|
||||||
|
blankHeaderFrames,
|
||||||
|
endScrollTop: scroll.scrollTop,
|
||||||
|
maximumScrollTop,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface CapturedCanvasFrame {
|
interface CapturedCanvasFrame {
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
image: Buffer;
|
image: Buffer;
|
||||||
@@ -454,23 +685,35 @@ async function expectContinuousFramesMatchStableRender(
|
|||||||
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const scroller = page.getByTestId("git-diff-scroll");
|
||||||
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
||||||
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
||||||
const firstOffset = await setHorizontalOffset(first, 240);
|
await setVerticalOffset(scroller, 0);
|
||||||
const secondOffset = await setHorizontalOffset(second, 120);
|
await expect(first).toBeAttached();
|
||||||
|
const firstOffset = await setHorizontalOffset(first, 40);
|
||||||
|
await setVerticalOffset(scroller, "end");
|
||||||
|
await expect(second).toBeAttached();
|
||||||
|
const secondOffset = await setHorizontalOffset(second, 20);
|
||||||
expect(firstOffset).not.toBe(secondOffset);
|
expect(firstOffset).not.toBe(secondOffset);
|
||||||
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
await setVerticalOffset(scroller, 0);
|
||||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
await expect(first).toBeAttached();
|
||||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
||||||
element.scrollTop = 0;
|
|
||||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
||||||
});
|
|
||||||
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
||||||
|
await setVerticalOffset(scroller, "end");
|
||||||
|
await expect(second).toBeAttached();
|
||||||
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setVerticalOffset(locator: Locator, offset: number | "end") {
|
||||||
|
await locator.evaluate(async (element, nextOffset) => {
|
||||||
|
element.scrollTop =
|
||||||
|
nextOffset === "end" ? element.scrollHeight - element.clientHeight : nextOffset;
|
||||||
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
);
|
||||||
|
}, offset);
|
||||||
|
}
|
||||||
|
|
||||||
async function setHorizontalOffset(locator: Locator, offset: number) {
|
async function setHorizontalOffset(locator: Locator, offset: number) {
|
||||||
return locator.evaluate(async (element, nextOffset) => {
|
return locator.evaluate(async (element, nextOffset) => {
|
||||||
element.scrollLeft = nextOffset;
|
element.scrollLeft = nextOffset;
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
|
|||||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||||
|
|
||||||
// Workspace B (same directory): the SAME directory-backed content is
|
// Workspace B (same directory): the SAME directory-backed content is
|
||||||
// visible even though the panel tabs belong to a different workspace.
|
// visible even though the panel tabs belong to a different workspace.
|
||||||
@@ -79,8 +79,8 @@ test.describe("Same-directory workspaces", () => {
|
|||||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await seeded.cleanup();
|
await seeded.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,16 +102,16 @@ export async function openChangesTreePanel(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await openChangesTreePanel(page);
|
await openChangesTreePanel(page);
|
||||||
const changedFile = page
|
const changedFile = page
|
||||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
await expect(changedFile).toBeVisible({ timeout });
|
||||||
await changedFile.click();
|
await changedFile.click();
|
||||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -162,5 +162,10 @@
|
|||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
"wrangler": "^4.105.0",
|
"wrangler": "^4.105.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
|
},
|
||||||
|
"reanimated": {
|
||||||
|
"staticFeatureFlags": {
|
||||||
|
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
|
|||||||
[
|
[
|
||||||
assistantId,
|
assistantId,
|
||||||
{
|
{
|
||||||
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
||||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||||
durationMs: 2_000,
|
durationMs: 2_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type { StreamHistoryRowRevision } from "./strategy";
|
||||||
|
|
||||||
|
interface HistoryRowDisplayVariants {
|
||||||
|
regular?: StreamItem;
|
||||||
|
compact?: StreamItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
||||||
|
|
||||||
|
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
||||||
|
let variants = historyRowDisplayVariants.get(item);
|
||||||
|
if (!variants) {
|
||||||
|
variants = {};
|
||||||
|
historyRowDisplayVariants.set(item, variants);
|
||||||
|
}
|
||||||
|
const key = compact ? "compact" : "regular";
|
||||||
|
variants[key] ??= { ...item };
|
||||||
|
return variants[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item identity is the render signal for history rows: the row boundary in view.tsx bails out
|
||||||
|
// until its item changes. Every viewport runs its history through this hook so a history host
|
||||||
|
// whose tool-call group keeps updating from the live head, a group whose expanded state changed,
|
||||||
|
// or a breakpoint change reaches the row as a fresh identity. Unchanged rows keep their identity,
|
||||||
|
// which is what limits a live update to the rows it touched.
|
||||||
|
export function useRevisedHistoryRows(
|
||||||
|
items: StreamItem[],
|
||||||
|
revision: StreamHistoryRowRevision | undefined,
|
||||||
|
): StreamItem[] {
|
||||||
|
const globalDisplayState = revision?.globalDisplayState ?? false;
|
||||||
|
const displayStateById = revision?.displayStateById;
|
||||||
|
const contentById = revision?.contentById;
|
||||||
|
const globallyRevisedRows = useMemo(
|
||||||
|
() => items.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)),
|
||||||
|
[items, globalDisplayState],
|
||||||
|
);
|
||||||
|
const displayStateRevisedRows = useMemo(
|
||||||
|
() => globallyRevisedRows.map((item) => (displayStateById?.has(item.id) ? { ...item } : item)),
|
||||||
|
[globallyRevisedRows, displayStateById],
|
||||||
|
);
|
||||||
|
return useMemo(
|
||||||
|
() => displayStateRevisedRows.map((item) => (contentById?.has(item.id) ? { ...item } : item)),
|
||||||
|
[displayStateRevisedRows, contentById],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,7 +73,6 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
|
|||||||
|
|
||||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||||
const timing = {
|
const timing = {
|
||||||
startedAt: timestamp(1),
|
|
||||||
completedAt: timestamp(9),
|
completedAt: timestamp(9),
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
};
|
};
|
||||||
@@ -366,6 +365,23 @@ describe("layoutStream", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("keeps layout item identity for rows whose layout did not change", () => {
|
||||||
|
const user = userMessage("u1", 1);
|
||||||
|
const first = toolCall("tool-1", 2);
|
||||||
|
const second = toolCall("tool-2", 3);
|
||||||
|
const before = layoutFor({ platform: "android", tail: [user, first, second] });
|
||||||
|
|
||||||
|
const third = toolCall("tool-3", 4);
|
||||||
|
const after = layoutFor({ platform: "android", tail: [user, first, second, third] });
|
||||||
|
|
||||||
|
// A newest-first list shifts every index, but only the rows next to the insertion change.
|
||||||
|
expect(findLayoutItem(after, user.id)).toBe(findLayoutItem(before, user.id));
|
||||||
|
expect(findLayoutItem(after, first.id)).toBe(findLayoutItem(before, first.id));
|
||||||
|
expect(findLayoutItem(after, second.id)).not.toBe(findLayoutItem(before, second.id));
|
||||||
|
expect(findLayoutItem(after, second.id).isLastInToolSequence).toBe(false);
|
||||||
|
expect(findLayoutItem(after, third.id).isLastInToolSequence).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||||
const shell = toolCall("tool-1", 2);
|
const shell = toolCall("tool-1", 2);
|
||||||
const thinking = thought("thought-1", 3);
|
const thinking = thought("thought-1", 3);
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export interface TurnFooterHost {
|
|||||||
|
|
||||||
export interface StreamLayoutItem {
|
export interface StreamLayoutItem {
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
index: number;
|
|
||||||
items: StreamItem[];
|
|
||||||
aboveItem: StreamItem | null;
|
aboveItem: StreamItem | null;
|
||||||
belowItem: StreamItem | null;
|
belowItem: StreamItem | null;
|
||||||
gapBelow: number;
|
gapBelow: number;
|
||||||
@@ -232,60 +230,109 @@ function getSegmentNeighbor(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
// Last layout emitted for each stream item. A row only rerenders when its layout item identity
|
||||||
return input.items.map((item, index) => {
|
// changes, so an item whose render-relevant layout is unchanged must keep its previous object even
|
||||||
const aboveItem = getSegmentNeighbor({
|
// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
|
||||||
strategy: input.strategy,
|
const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
|
||||||
items: input.items,
|
|
||||||
index,
|
|
||||||
relation: "above",
|
|
||||||
boundaryIndex: input.boundaryIndex,
|
|
||||||
boundaryItem: input.boundaryAboveItem,
|
|
||||||
});
|
|
||||||
const belowItem = getSegmentNeighbor({
|
|
||||||
strategy: input.strategy,
|
|
||||||
items: input.items,
|
|
||||||
index,
|
|
||||||
relation: "below",
|
|
||||||
boundaryIndex: input.boundaryIndex,
|
|
||||||
boundaryItem: input.boundaryBelowItem,
|
|
||||||
});
|
|
||||||
const completedFooter = resolveCompletedFooter({
|
|
||||||
strategy: input.strategy,
|
|
||||||
items: input.items,
|
|
||||||
index,
|
|
||||||
item,
|
|
||||||
belowItem,
|
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
|
||||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
|
||||||
boundaryAboveItems: input.boundaryAboveItems,
|
|
||||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
|
||||||
});
|
|
||||||
const assistantSpacing = getAssistantBlockSpacing({
|
|
||||||
item,
|
|
||||||
aboveItem,
|
|
||||||
belowItem,
|
|
||||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
function areTurnFooterHostsEqual(
|
||||||
item,
|
left: TurnFooterHost | null,
|
||||||
index,
|
right: TurnFooterHost | null,
|
||||||
items: input.items,
|
): boolean {
|
||||||
aboveItem,
|
if (left === right) return true;
|
||||||
belowItem,
|
if (!left || !right) return false;
|
||||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
return (
|
||||||
assistantSpacing,
|
left.itemId === right.itemId &&
|
||||||
completedFooter,
|
left.timing === right.timing &&
|
||||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
left.startIndex === right.startIndex &&
|
||||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
left.items === right.items
|
||||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
);
|
||||||
isLastInToolSequence:
|
}
|
||||||
isToolSequenceItem(item) &&
|
|
||||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
|
||||||
frameOrder: input.frameOrder,
|
return (
|
||||||
phase: input.phase,
|
previous.item === next.item &&
|
||||||
};
|
previous.aboveItem === next.aboveItem &&
|
||||||
|
previous.belowItem === next.belowItem &&
|
||||||
|
previous.gapBelow === next.gapBelow &&
|
||||||
|
previous.assistantSpacing === next.assistantSpacing &&
|
||||||
|
areTurnFooterHostsEqual(previous.completedFooter, next.completedFooter) &&
|
||||||
|
previous.toolSequence === next.toolSequence &&
|
||||||
|
previous.isFirstInUserGroup === next.isFirstInUserGroup &&
|
||||||
|
previous.isLastInUserGroup === next.isLastInUserGroup &&
|
||||||
|
previous.isLastInToolSequence === next.isLastInToolSequence &&
|
||||||
|
previous.frameOrder === next.frameOrder &&
|
||||||
|
previous.phase === next.phase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLayoutItem(next: StreamLayoutItem): StreamLayoutItem {
|
||||||
|
const previous = previousLayoutItemByStreamItem.get(next.item);
|
||||||
|
if (previous && areLayoutItemsEquivalent(previous, next)) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
previousLayoutItemByStreamItem.set(next.item, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||||
|
return input.items.map((item, index) => layoutSegmentItem(input, item, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutSegmentItem(
|
||||||
|
input: LayoutSegmentInput,
|
||||||
|
item: StreamItem,
|
||||||
|
index: number,
|
||||||
|
): StreamLayoutItem {
|
||||||
|
const aboveItem = getSegmentNeighbor({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: input.items,
|
||||||
|
index,
|
||||||
|
relation: "above",
|
||||||
|
boundaryIndex: input.boundaryIndex,
|
||||||
|
boundaryItem: input.boundaryAboveItem,
|
||||||
|
});
|
||||||
|
const belowItem = getSegmentNeighbor({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: input.items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
boundaryIndex: input.boundaryIndex,
|
||||||
|
boundaryItem: input.boundaryBelowItem,
|
||||||
|
});
|
||||||
|
const completedFooter = resolveCompletedFooter({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: input.items,
|
||||||
|
index,
|
||||||
|
item,
|
||||||
|
belowItem,
|
||||||
|
timingByAssistantId: input.timingByAssistantId,
|
||||||
|
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||||
|
boundaryAboveItems: input.boundaryAboveItems,
|
||||||
|
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||||
|
});
|
||||||
|
const assistantSpacing = getAssistantBlockSpacing({
|
||||||
|
item,
|
||||||
|
aboveItem,
|
||||||
|
belowItem,
|
||||||
|
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||||
|
});
|
||||||
|
|
||||||
|
return shareLayoutItem({
|
||||||
|
item,
|
||||||
|
aboveItem,
|
||||||
|
belowItem,
|
||||||
|
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||||
|
assistantSpacing,
|
||||||
|
completedFooter,
|
||||||
|
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||||
|
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||||
|
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||||
|
isLastInToolSequence:
|
||||||
|
isToolSequenceItem(item) &&
|
||||||
|
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||||
|
frameOrder: input.frameOrder,
|
||||||
|
phase: input.phase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||||
startedAt: tail[2]?.timestamp,
|
|
||||||
completedAt: tail[3]?.timestamp,
|
completedAt: tail[3]?.timestamp,
|
||||||
durationMs: 1000,
|
durationMs: 1000,
|
||||||
});
|
});
|
||||||
@@ -176,7 +175,6 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||||
startedAt: tail[0]?.timestamp,
|
|
||||||
completedAt: head[0]?.timestamp,
|
completedAt: head[0]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
@@ -196,7 +194,6 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||||
startedAt: tail[0]?.timestamp,
|
|
||||||
completedAt: tail[1]?.timestamp,
|
completedAt: tail[1]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||||
@@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
|
|||||||
};
|
};
|
||||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||||
|
|
||||||
interface HistoryRowDisplayVariants {
|
|
||||||
regular?: StreamItem;
|
|
||||||
compact?: StreamItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
|
||||||
|
|
||||||
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
|
||||||
let variants = historyRowDisplayVariants.get(item);
|
|
||||||
if (!variants) {
|
|
||||||
variants = {};
|
|
||||||
historyRowDisplayVariants.set(item, variants);
|
|
||||||
}
|
|
||||||
const key = compact ? "compact" : "regular";
|
|
||||||
variants[key] ??= { ...item };
|
|
||||||
return variants[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyExtractor(item: { id: string }): string {
|
function keyExtractor(item: { id: string }): string {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
@@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||||
// Keep unchanged item identities intact so live updates only rerender rows
|
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
|
||||||
// whose projected content or local display state actually changed. A rare
|
|
||||||
// breakpoint change intentionally refreshes the whole history window.
|
|
||||||
const globallyRevisedHistoryRows = useMemo(() => {
|
|
||||||
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
|
|
||||||
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
|
|
||||||
}, [historyItems, historyRowRevision?.globalDisplayState]);
|
|
||||||
const displayStateHistoryRows = useMemo(
|
|
||||||
() =>
|
|
||||||
globallyRevisedHistoryRows.map((item) =>
|
|
||||||
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
|
|
||||||
),
|
|
||||||
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
|
|
||||||
);
|
|
||||||
const historyRows = useMemo(
|
|
||||||
() =>
|
|
||||||
displayStateHistoryRows.map((item) =>
|
|
||||||
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
|
|
||||||
),
|
|
||||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
|
||||||
);
|
|
||||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
||||||
const metrics = streamViewportMetricsRef.current;
|
const metrics = streamViewportMetricsRef.current;
|
||||||
const hasMeasuredViewport =
|
const hasMeasuredViewport =
|
||||||
|
|||||||
@@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => {
|
|||||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rerenders only the history row whose content revision changed", () => {
|
||||||
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||||
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||||
|
const host = userMessage(1);
|
||||||
|
const other = userMessage(2);
|
||||||
|
const renderCountById = new Map<string, number>();
|
||||||
|
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
|
||||||
|
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
|
||||||
|
return <div>{item.id}</div>;
|
||||||
|
});
|
||||||
|
const renderInput: StreamRenderInput = {
|
||||||
|
agentId: "agent",
|
||||||
|
segments: {
|
||||||
|
historyVirtualized: [],
|
||||||
|
historyMounted: [host, other],
|
||||||
|
liveHead: [],
|
||||||
|
},
|
||||||
|
boundary: {
|
||||||
|
hasVirtualizedHistory: false,
|
||||||
|
hasMountedHistory: true,
|
||||||
|
hasLiveHead: false,
|
||||||
|
},
|
||||||
|
renderers: {
|
||||||
|
...createRenderers(vi.fn()),
|
||||||
|
renderHistoryMountedRow: (item) => <HistoryRow item={item} />,
|
||||||
|
},
|
||||||
|
listEmptyComponent: null,
|
||||||
|
viewportRef,
|
||||||
|
routeBottomAnchorRequest: null,
|
||||||
|
isAuthoritativeHistoryReady: true,
|
||||||
|
onNearBottomChange: vi.fn(),
|
||||||
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||||
|
isLoadingOlderHistory: false,
|
||||||
|
hasOlderHistory: false,
|
||||||
|
olderHistoryProgressKey: null,
|
||||||
|
scrollEnabled: true,
|
||||||
|
listStyle: null,
|
||||||
|
baseListContentContainerStyle: null,
|
||||||
|
forwardListContentContainerStyle: null,
|
||||||
|
};
|
||||||
|
const unrevised = {
|
||||||
|
contentById: new Set<string>(),
|
||||||
|
displayStateById: new Set<string>(),
|
||||||
|
globalDisplayState: false,
|
||||||
|
};
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised }));
|
||||||
|
});
|
||||||
|
expect(renderCountById.get(host.id)).toBe(1);
|
||||||
|
expect(renderCountById.get(other.id)).toBe(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
strategy.render({
|
||||||
|
...renderInput,
|
||||||
|
historyRowRevision: { ...unrevised, contentById: new Set([host.id]) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(renderCountById.get(host.id)).toBe(2);
|
||||||
|
expect(renderCountById.get(other.id)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
||||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
shouldAdjustScrollForVirtualRowResize,
|
shouldAdjustScrollForVirtualRowResize,
|
||||||
} from "./web-virtualization";
|
} from "./web-virtualization";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||||
|
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||||
import { createStreamStrategy } from "./strategy";
|
import { createStreamStrategy } from "./strategy";
|
||||||
import {
|
import {
|
||||||
abandonHistoryStartPaginationRequest,
|
abandonHistoryStartPaginationRequest,
|
||||||
@@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom(
|
|||||||
|
|
||||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||||
const {
|
const {
|
||||||
segments,
|
segments: inputSegments,
|
||||||
|
historyRowRevision,
|
||||||
liveHeadRowRevision,
|
liveHeadRowRevision,
|
||||||
boundary,
|
boundary,
|
||||||
renderers,
|
renderers,
|
||||||
@@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollEnabled,
|
scrollEnabled,
|
||||||
isMobileBreakpoint,
|
isMobileBreakpoint,
|
||||||
} = props;
|
} = props;
|
||||||
|
const historyVirtualized = useRevisedHistoryRows(
|
||||||
|
inputSegments.historyVirtualized,
|
||||||
|
historyRowRevision,
|
||||||
|
);
|
||||||
|
const historyMounted = useRevisedHistoryRows(inputSegments.historyMounted, historyRowRevision);
|
||||||
|
const segments = useMemo(
|
||||||
|
() => ({ ...inputSegments, historyVirtualized, historyMounted }),
|
||||||
|
[historyMounted, historyVirtualized, inputSegments],
|
||||||
|
);
|
||||||
const isActive = useRetainedPanelActive();
|
const isActive = useRetainedPanelActive();
|
||||||
const isActiveRef = useRef(isActive);
|
const isActiveRef = useRef(isActive);
|
||||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ describe("canonical turn membership", () => {
|
|||||||
assistant("heartbeat-2-reply", 6, "turn-3"),
|
assistant("heartbeat-2-reply", 6, "turn-3"),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
|
const completed = layoutFor(items, false);
|
||||||
|
|
||||||
|
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
||||||
|
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||||
|
completedAt: at(6),
|
||||||
|
durationMs: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -118,7 +124,6 @@ describe("canonical turn membership", () => {
|
|||||||
const completed = layoutFor(completedItems, false);
|
const completed = layoutFor(completedItems, false);
|
||||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||||
startedAt: at(1),
|
|
||||||
completedAt: at(9),
|
completedAt: at(9),
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,6 +220,22 @@ function renderListEmptyComponent(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
|
||||||
|
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
|
||||||
|
// what stops that churn: a row renders again only when its stream item identity, its layout item
|
||||||
|
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
|
||||||
|
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
|
||||||
|
const HistoryStreamRow = memo(function HistoryStreamRow({
|
||||||
|
layoutItem,
|
||||||
|
renderStreamItem,
|
||||||
|
}: {
|
||||||
|
item: StreamItem;
|
||||||
|
layoutItem: StreamLayoutItem;
|
||||||
|
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||||
|
}) {
|
||||||
|
return <>{renderStreamItem(layoutItem)}</>;
|
||||||
|
});
|
||||||
|
|
||||||
function renderHistoryStreamItem(input: {
|
function renderHistoryStreamItem(input: {
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
layoutItemById: Map<string, StreamLayoutItem>;
|
layoutItemById: Map<string, StreamLayoutItem>;
|
||||||
@@ -229,7 +245,13 @@ function renderHistoryStreamItem(input: {
|
|||||||
if (!layoutItem) {
|
if (!layoutItem) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return input.renderStreamItem(layoutItem);
|
return (
|
||||||
|
<HistoryStreamRow
|
||||||
|
item={input.item}
|
||||||
|
layoutItem={layoutItem}
|
||||||
|
renderStreamItem={input.renderStreamItem}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLiveHeadStreamItem(input: {
|
function renderLiveHeadStreamItem(input: {
|
||||||
@@ -789,9 +811,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Read through a stable event so live group updates do not change the renderer identity
|
||||||
|
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
|
||||||
|
const getToolCallGroup = useStableEvent((hostId: string) =>
|
||||||
|
projectedToolCalls.groupsByHostId.get(hostId),
|
||||||
|
);
|
||||||
const renderToolCallItem = useCallback(
|
const renderToolCallItem = useCallback(
|
||||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||||
const group = projectedToolCalls.groupsByHostId.get(item.id);
|
const group = getToolCallGroup(item.id);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||||
}
|
}
|
||||||
@@ -818,8 +845,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
projectedToolCalls.groupsByHostId,
|
|
||||||
expandedToolCallGroupIds,
|
expandedToolCallGroupIds,
|
||||||
|
getToolCallGroup,
|
||||||
renderSingleToolCallItem,
|
renderSingleToolCallItem,
|
||||||
setToolCallGroupExpanded,
|
setToolCallGroupExpanded,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
|||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import {
|
import {
|
||||||
KeyboardActionDispatcherProvider,
|
KeyboardActionDispatcherProvider,
|
||||||
@@ -114,7 +114,7 @@ import {
|
|||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||||
import { getNextThemePreference } from "@/styles/theme";
|
import { getNextThemePreference } from "@/styles/theme";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -627,10 +627,9 @@ function SidebarChrome({
|
|||||||
keyboardShortcutsEnabled: boolean;
|
keyboardShortcutsEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const isOpen = usePanelStore((state) =>
|
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
);
|
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||||
const active = visible && isOpen;
|
|
||||||
return (
|
return (
|
||||||
<SidebarModelProvider active={active}>
|
<SidebarModelProvider active={active}>
|
||||||
{mounted ? <LeftSidebar active={active} /> : null}
|
{mounted ? <LeftSidebar active={active} /> : null}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
NativeExplorerSidebarDock,
|
NativeExplorerSidebarDock,
|
||||||
} from "@/components/compact-explorer-sidebar";
|
} from "@/components/compact-explorer-sidebar";
|
||||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||||
@@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
): CompactExplorerSidebarHostModel | null {
|
): CompactExplorerSidebarHostModel | null {
|
||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||||
@@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
const resolvedModel = useMemo(
|
const resolvedModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveCompactExplorerSidebarHostModel({
|
resolveCompactExplorerSidebarHostModel({
|
||||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||||
selection,
|
selection,
|
||||||
workspace,
|
workspace,
|
||||||
isGit: checkoutQuery.data?.isGit ?? false,
|
isGit: checkoutQuery.data?.isGit ?? false,
|
||||||
}),
|
}),
|
||||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
retainedModelRef.current = null;
|
retainedModelRef.current = null;
|
||||||
if (enabled && isExplorerOpen) {
|
if (enabled && isExplorerActive) {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isExplorerOpen) {
|
if (!isExplorerActive) {
|
||||||
retainedModelRef.current = null;
|
retainedModelRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolvedModel) {
|
if (resolvedModel) {
|
||||||
retainedModelRef.current = resolvedModel;
|
retainedModelRef.current = resolvedModel;
|
||||||
}
|
}
|
||||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||||
|
|
||||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompactExplorerSidebarHostProps {
|
interface CompactExplorerSidebarHostProps {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
@@ -81,7 +82,7 @@ export function CompactExplorerSidebar({
|
|||||||
}: ExplorerSidebarProps) {
|
}: ExplorerSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
const isActive = useIsMobilePanelActive("file-explorer");
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -99,11 +100,11 @@ export function CompactExplorerSidebar({
|
|||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen,
|
isOpen: isActive,
|
||||||
});
|
});
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
},
|
},
|
||||||
[isOpen, showMobileAgent],
|
[isActive, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||||
@@ -127,7 +128,7 @@ export function CompactExplorerSidebar({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RetainedPanelActivity active={isOpen}>
|
<RetainedPanelActivity active={isActive}>
|
||||||
<MobilePanelOverlay
|
<MobilePanelOverlay
|
||||||
panel="file-explorer"
|
panel="file-explorer"
|
||||||
closeGesture={closeGesture}
|
closeGesture={closeGesture}
|
||||||
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isOpen={isOpen}
|
isOpen={isActive}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</MobilePanelOverlay>
|
</MobilePanelOverlay>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { formatDiffCount } from "@/git/file-header-presentation";
|
||||||
|
|
||||||
interface DiffStatProps {
|
interface DiffStatProps {
|
||||||
additions: number;
|
additions: number;
|
||||||
@@ -7,15 +8,6 @@ interface DiffStatProps {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
|
||||||
notation: "compact",
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function formatDiffCount(value: number): string {
|
|
||||||
return compactFormatter.format(value).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row} testID={testID}>
|
<View style={styles.row} testID={testID}>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
|
|
||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
buildNewWorkspaceRoute,
|
buildNewWorkspaceRoute,
|
||||||
@@ -121,6 +120,7 @@ interface SidebarLabels {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
|
active: boolean;
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
insetsBottom: number;
|
insetsBottom: number;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
@@ -273,6 +273,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
<RetainedPanelActivity active={active}>
|
<RetainedPanelActivity active={active}>
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
|
active={active}
|
||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
closeSidebar={showMobileAgent}
|
closeSidebar={showMobileAgent}
|
||||||
@@ -604,6 +605,7 @@ function SidebarFooter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
|
active,
|
||||||
theme,
|
theme,
|
||||||
workspaceGroups,
|
workspaceGroups,
|
||||||
projectIconTargets,
|
projectIconTargets,
|
||||||
@@ -638,7 +640,6 @@ function MobileSidebar({
|
|||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||||
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
|
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
@@ -737,7 +738,7 @@ function MobileSidebar({
|
|||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={active}
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -578,7 +578,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
interface AssistantTurnFooterProps {
|
interface AssistantTurnFooterProps {
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
durationMs?: number;
|
durationMs?: number | null;
|
||||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,9 +616,8 @@ const TIMESTAMP_REVEAL_MS = 3000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Footer rendered next to the copy button at the end of an assistant turn.
|
* Footer rendered next to the copy button at the end of an assistant turn.
|
||||||
* Always shows the turn duration; swaps to the end timestamp on hover (web)
|
* Shows the turn duration and swaps to the end timestamp when both are known.
|
||||||
* or tap (native). The hidden sizer keeps the label width stable while the
|
* A turn without a visible start shows its end timestamp directly.
|
||||||
* visible text swaps.
|
|
||||||
*/
|
*/
|
||||||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||||
getContent,
|
getContent,
|
||||||
@@ -640,7 +639,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const durationLabel = useMemo(
|
const durationLabel = useMemo(
|
||||||
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
|
() =>
|
||||||
|
durationMs !== undefined && durationMs !== null
|
||||||
|
? `Worked for ${formatDuration(durationMs)}`
|
||||||
|
: "",
|
||||||
[durationMs],
|
[durationMs],
|
||||||
);
|
);
|
||||||
const timestampLabel = useMemo(
|
const timestampLabel = useMemo(
|
||||||
@@ -648,7 +650,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
[completedAt],
|
[completedAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canSwap = Boolean(timestampLabel);
|
const primaryLabel = durationLabel || timestampLabel;
|
||||||
|
const canSwap = Boolean(durationLabel && timestampLabel);
|
||||||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||||||
|
|
||||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||||
@@ -679,22 +682,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||||
/>
|
/>
|
||||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||||
{durationLabel ? (
|
{primaryLabel ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onHoverIn={handleHoverIn}
|
onHoverIn={handleHoverIn}
|
||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
accessibilityRole={canSwap ? "button" : undefined}
|
accessibilityRole={canSwap ? "button" : undefined}
|
||||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||||||
>
|
>
|
||||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||||
{/* Sizer reserves space for whichever label is longer so the
|
{/* Sizer reserves space for whichever label is longer so the
|
||||||
container width is stable across hover transitions. */}
|
container width is stable across hover transitions. */}
|
||||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||||
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
|
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||||
{showTimestamp ? timestampLabel : durationLabel}
|
{showTimestamp ? timestampLabel : primaryLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
|
|||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectHeaderRowProps {
|
interface ProjectHeaderRowProps {
|
||||||
@@ -1540,7 +1540,7 @@ function ProjectBlock({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
useNestable,
|
useNestable,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
hostBadgeByServerId,
|
hostBadgeByServerId,
|
||||||
@@ -1565,7 +1565,7 @@ function ProjectBlock({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
useNestable: boolean;
|
useNestable: boolean;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
creatingWorkspaceIds: ReadonlySet<string>;
|
creatingWorkspaceIds: ReadonlySet<string>;
|
||||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
@@ -1745,7 +1745,7 @@ function ProjectBlock({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={useNestable}
|
nestable={useNestable}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
{canToggleWorkspaces ? (
|
{canToggleWorkspaces ? (
|
||||||
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.isDragging === next.isDragging &&
|
previous.isDragging === next.isDragging &&
|
||||||
previous.dragHandleProps === next.dragHandleProps &&
|
previous.dragHandleProps === next.dragHandleProps &&
|
||||||
previous.useNestable === next.useNestable &&
|
previous.useNestable === next.useNestable &&
|
||||||
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
|
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||||
areProjectBlockSelectionsEqual(previous, next)
|
areProjectBlockSelectionsEqual(previous, next)
|
||||||
);
|
);
|
||||||
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
|
|||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProjectModeList
|
<ProjectModeList
|
||||||
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
|
|||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
hostBadgeByServerId={hostBadgeByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
}: {
|
}: {
|
||||||
workspaceGroups: SidebarWorkspaceGroup[];
|
workspaceGroups: SidebarWorkspaceGroup[];
|
||||||
pinnedGroups: PinnedSidebarGroups;
|
pinnedGroups: PinnedSidebarGroups;
|
||||||
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
sidebarFilterEmpty: boolean;
|
sidebarFilterEmpty: boolean;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const showShortcutBadges = useShowShortcutBadges();
|
const showShortcutBadges = useShowShortcutBadges();
|
||||||
const pinnedWorkspaces = useMemo(
|
const pinnedWorkspaces = useMemo(
|
||||||
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2072,7 +2072,7 @@ function ProjectModeList({
|
|||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
hasActiveProjectFilter,
|
hasActiveProjectFilter,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
pathname,
|
pathname,
|
||||||
hostBadgeByServerId,
|
hostBadgeByServerId,
|
||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
@@ -2289,7 +2289,7 @@ function ProjectModeList({
|
|||||||
isDragging={dragState.isDragging}
|
isDragging={dragState.isDragging}
|
||||||
dragHandleProps={dragState.dragHandleProps}
|
dragHandleProps={dragState.dragHandleProps}
|
||||||
useNestable={platformIsNative}
|
useNestable={platformIsNative}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
hostBadgeByServerId={hostBadgeByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
@@ -2311,7 +2311,7 @@ function ProjectModeList({
|
|||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -2388,7 +2388,7 @@ function ProjectModeList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2411,7 +2411,7 @@ function ProjectModeList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
{canTogglePinnedChats ? (
|
{canTogglePinnedChats ? (
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
|
|||||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||||
sidebarFilterEmpty?: boolean;
|
sidebarFilterEmpty?: boolean;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
|
|||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty = false,
|
sidebarFilterEmpty = false,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedWorkspaceGroupKeys,
|
(state) => state.collapsedWorkspaceGroupKeys,
|
||||||
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
/>
|
/>
|
||||||
{canTogglePinnedWorkspaces ? (
|
{canTogglePinnedWorkspaces ? (
|
||||||
<SidebarGroupToggleRow
|
<SidebarGroupToggleRow
|
||||||
|
|||||||
@@ -131,6 +131,46 @@ describe("EditingTextInputNative", () => {
|
|||||||
expect(document.activeElement).toBe(container?.querySelector("input"));
|
expect(document.activeElement).toBe(container?.querySelector("input"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("focuses the replacement input when focus is requested before a cleared input remounts", () => {
|
||||||
|
const handleRef = createRef<EditingTextInputHandle>();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
|
||||||
|
});
|
||||||
|
const originalInput = container?.querySelector("input");
|
||||||
|
if (!originalInput) throw new Error("Expected native input");
|
||||||
|
const originalFocus = vi.spyOn(originalInput, "focus");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handleRef.current?.replaceText("");
|
||||||
|
handleRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacementInput = container?.querySelector("input");
|
||||||
|
expect(replacementInput).not.toBe(originalInput);
|
||||||
|
expect(originalFocus).not.toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).toBe(replacementInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a pending focus restore when blur is requested before a cleared input remounts", () => {
|
||||||
|
const handleRef = createRef<EditingTextInputHandle>();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
|
||||||
|
});
|
||||||
|
const originalInput = container?.querySelector("input");
|
||||||
|
if (!originalInput) throw new Error("Expected native input");
|
||||||
|
Object.assign(originalInput, { isFocused: () => true });
|
||||||
|
originalInput.focus();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handleRef.current?.replaceText("");
|
||||||
|
handleRef.current?.blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(container?.querySelector("input"));
|
||||||
|
});
|
||||||
|
|
||||||
it("updates textRef and text when replaceText receives non-empty text", () => {
|
it("updates textRef and text when replaceText receives non-empty text", () => {
|
||||||
const handleRef = createRef<EditingTextInputHandle>();
|
const handleRef = createRef<EditingTextInputHandle>();
|
||||||
|
|
||||||
|
|||||||
@@ -35,31 +35,54 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
const inputRef = useRef<NativeInput | null>(null);
|
const inputRef = useRef<NativeInput | null>(null);
|
||||||
const initialTextRef = useRef(initialValue);
|
const initialTextRef = useRef(initialValue);
|
||||||
const textRef = useRef(initialTextRef.current);
|
const textRef = useRef(initialTextRef.current);
|
||||||
const restoreFocusAfterResetRef = useRef(false);
|
// Clearing swaps the native input for a fresh instance (see replaceText).
|
||||||
const [nativeInputRevision, setNativeInputRevision] = useState(0);
|
// Until React commits that swap, `inputRef` still points at the doomed
|
||||||
|
// instance: focusing it asks Android for the keyboard and then tears the
|
||||||
|
// focused view down, which cancels the show. Fabric also runs view
|
||||||
|
// commands before mount items, so a focus command sent alongside the swap
|
||||||
|
// reaches a view that is not attached yet and the IME ignores it. Focus is
|
||||||
|
// therefore carried by the replacement's `autoFocus`, which native applies
|
||||||
|
// once the view is attached.
|
||||||
|
const isAwaitingReplacementRef = useRef(false);
|
||||||
|
const [replacement, setReplacement] = useState({ revision: 0, autoFocus: false });
|
||||||
|
|
||||||
const assignInputRef = useCallback((input: NativeInput | null) => {
|
const assignInputRef = useCallback((input: NativeInput | null) => {
|
||||||
inputRef.current = input;
|
inputRef.current = input;
|
||||||
if (!input || !restoreFocusAfterResetRef.current) return;
|
if (input) isAwaitingReplacementRef.current = false;
|
||||||
restoreFocusAfterResetRef.current = false;
|
}, []);
|
||||||
input.focus();
|
|
||||||
|
const setReplacementFocus = useCallback((autoFocus: boolean) => {
|
||||||
|
setReplacement((current) => ({ ...current, autoFocus }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => inputRef.current?.focus(),
|
focus: () => {
|
||||||
blur: () => inputRef.current?.blur(),
|
if (isAwaitingReplacementRef.current) {
|
||||||
|
setReplacementFocus(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
blur: () => {
|
||||||
|
if (isAwaitingReplacementRef.current) {
|
||||||
|
setReplacementFocus(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
||||||
getText: () => textRef.current,
|
getText: () => textRef.current,
|
||||||
replaceText: (nextText, selection) => {
|
replaceText: (nextText, selection) => {
|
||||||
textRef.current = nextText;
|
textRef.current = nextText;
|
||||||
if (nextText === "") {
|
if (nextText === "") {
|
||||||
restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false;
|
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||||
if (inputRef.current?.replaceText) {
|
if (inputRef.current?.replaceText) {
|
||||||
inputRef.current.replaceText(nextText, selection);
|
inputRef.current.replaceText(nextText, selection);
|
||||||
} else {
|
} else {
|
||||||
inputRef.current?.clear?.();
|
inputRef.current?.clear?.();
|
||||||
}
|
}
|
||||||
setNativeInputRevision((revision) => revision + 1);
|
isAwaitingReplacementRef.current = true;
|
||||||
|
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inputRef.current?.replaceText) {
|
if (inputRef.current?.replaceText) {
|
||||||
@@ -93,11 +116,14 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
[onPasteError, onPasteImages],
|
[onPasteError, onPasteImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const autoFocus = replacement.revision === 0 ? props.autoFocus : replacement.autoFocus;
|
||||||
|
|
||||||
if (onPasteImages || onPasteError) {
|
if (onPasteImages || onPasteError) {
|
||||||
return (
|
return (
|
||||||
<PasteInput
|
<PasteInput
|
||||||
{...props}
|
{...props}
|
||||||
key={nativeInputRevision}
|
autoFocus={autoFocus}
|
||||||
|
key={replacement.revision}
|
||||||
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
|
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
|
||||||
defaultValue={textRef.current}
|
defaultValue={textRef.current}
|
||||||
onChangeText={handleChangeText}
|
onChangeText={handleChangeText}
|
||||||
@@ -109,7 +135,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
return (
|
return (
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
{...props}
|
{...props}
|
||||||
key={nativeInputRevision}
|
autoFocus={autoFocus}
|
||||||
|
key={replacement.revision}
|
||||||
ref={assignInputRef as unknown as React.Ref<never>}
|
ref={assignInputRef as unknown as React.Ref<never>}
|
||||||
defaultValue={textRef.current}
|
defaultValue={textRef.current}
|
||||||
onChangeText={handleChangeText}
|
onChangeText={handleChangeText}
|
||||||
@@ -119,7 +146,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...props}
|
{...props}
|
||||||
key={nativeInputRevision}
|
autoFocus={autoFocus}
|
||||||
|
key={replacement.revision}
|
||||||
ref={assignInputRef as React.Ref<TextInput>}
|
ref={assignInputRef as React.Ref<TextInput>}
|
||||||
defaultValue={textRef.current}
|
defaultValue={textRef.current}
|
||||||
onChangeText={handleChangeText}
|
onChangeText={handleChangeText}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface DocumentFileHeaderProps {
|
|||||||
mode: DiffDocumentProps["mode"];
|
mode: DiffDocumentProps["mode"];
|
||||||
onToggleFile: (path: string) => void;
|
onToggleFile: (path: string) => void;
|
||||||
onSelectPath: (path: string) => void;
|
onSelectPath: (path: string) => void;
|
||||||
|
canvasRendered?: boolean;
|
||||||
|
onActiveChange?: (active: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||||
@@ -17,6 +19,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
|||||||
mode,
|
mode,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onSelectPath,
|
onSelectPath,
|
||||||
|
canvasRendered = false,
|
||||||
|
onActiveChange,
|
||||||
}: DocumentFileHeaderProps) {
|
}: DocumentFileHeaderProps) {
|
||||||
const activate = useCallback(
|
const activate = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
@@ -47,6 +51,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
|||||||
onDuplicate={working?.onDuplicate}
|
onDuplicate={working?.onDuplicate}
|
||||||
onRevert={working?.onRevert}
|
onRevert={working?.onRevert}
|
||||||
testID={`diff-file-${file.fileIndex}`}
|
testID={`diff-file-${file.fileIndex}`}
|
||||||
|
canvasRendered={canvasRendered}
|
||||||
|
onActiveChange={onActiveChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, documentFileHeaderPropsEqual);
|
}, documentFileHeaderPropsEqual);
|
||||||
@@ -84,6 +90,8 @@ function documentFileHeaderIdentityMatches(
|
|||||||
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
|
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
|
||||||
previous.onToggleFile !== next.onToggleFile ||
|
previous.onToggleFile !== next.onToggleFile ||
|
||||||
previous.onSelectPath !== next.onSelectPath ||
|
previous.onSelectPath !== next.onSelectPath ||
|
||||||
|
previous.canvasRendered !== next.canvasRendered ||
|
||||||
|
previous.onActiveChange !== next.onActiveChange ||
|
||||||
previous.mode.kind !== next.mode.kind
|
previous.mode.kind !== next.mode.kind
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
diffHeaderViewportKey,
|
||||||
|
diffInteractionWindowTop,
|
||||||
|
resolveVisibleFileSections,
|
||||||
|
} from "./header-layout";
|
||||||
|
import type { DiffFileSection } from "./types";
|
||||||
|
|
||||||
|
describe("diff header viewport", () => {
|
||||||
|
it("keeps a many-file document bounded by the viewport", () => {
|
||||||
|
const files = Array.from({ length: 2_000 }, (_, index) => collapsedFile(index));
|
||||||
|
|
||||||
|
const window = resolveVisibleFileSections({
|
||||||
|
files,
|
||||||
|
scrollTop: 30_000,
|
||||||
|
viewportHeight: 600,
|
||||||
|
overscan: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.files.length).toBeLessThanOrEqual(61);
|
||||||
|
expect(window.sticky?.file.fileIndex).toBe(1_000);
|
||||||
|
expect(window.sticky?.y).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins an expanded file whose original header is outside overscan", () => {
|
||||||
|
const files = [expandedFile(0, 4_000), collapsedFile(1, 4_030)];
|
||||||
|
|
||||||
|
const window = resolveVisibleFileSections({
|
||||||
|
files,
|
||||||
|
scrollTop: 2_000,
|
||||||
|
viewportHeight: 600,
|
||||||
|
overscan: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.files.map((file) => file.path)).toEqual(["file-0.ts"]);
|
||||||
|
expect(window.sticky).toEqual({ file: files[0], y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes the pinned header out one pixel at a time", () => {
|
||||||
|
const files = [expandedFile(0, 100), collapsedFile(1, 130)];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveVisibleFileSections({ files, scrollTop: 129, viewportHeight: 100, overscan: 0 }).sticky
|
||||||
|
?.y,
|
||||||
|
).toBe(-29);
|
||||||
|
expect(
|
||||||
|
resolveVisibleFileSections({ files, scrollTop: 130, viewportHeight: 100, overscan: 0 }).sticky
|
||||||
|
?.y,
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses a fixed sticky header until its visible layout changes", () => {
|
||||||
|
const files = [section(0, 0, 4_000, false), section(1, 4_000, 4_030, true)];
|
||||||
|
const keyAt = (scrollTop: number) =>
|
||||||
|
diffHeaderViewportKey({ files, scrollTop, viewportHeight: 600, activePath: null });
|
||||||
|
|
||||||
|
expect(keyAt(100)).toBe(keyAt(2_000));
|
||||||
|
expect(keyAt(3_985)).not.toBe(keyAt(2_000));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diff interaction window", () => {
|
||||||
|
it("retains one shell window across two viewport heights", () => {
|
||||||
|
expect(diffInteractionWindowTop(0, 500)).toBe(0);
|
||||||
|
expect(diffInteractionWindowTop(999, 500)).toBe(0);
|
||||||
|
expect(diffInteractionWindowTop(1_000, 500)).toBe(1_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function collapsedFile(fileIndex: number, top = fileIndex * 30): DiffFileSection {
|
||||||
|
return section(fileIndex, top, top + 30, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandedFile(fileIndex: number, bodyHeight: number): DiffFileSection {
|
||||||
|
const top = fileIndex === 0 ? 0 : bodyHeight - 30;
|
||||||
|
return section(fileIndex, top, top + 30 + bodyHeight, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(
|
||||||
|
fileIndex: number,
|
||||||
|
top: number,
|
||||||
|
bottom: number,
|
||||||
|
isCollapsed: boolean,
|
||||||
|
): DiffFileSection {
|
||||||
|
const path = `file-${fileIndex}.ts`;
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
path,
|
||||||
|
oldPath: undefined,
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
isNew: false,
|
||||||
|
isDeleted: false,
|
||||||
|
hunks: [],
|
||||||
|
},
|
||||||
|
fileIndex,
|
||||||
|
path,
|
||||||
|
top,
|
||||||
|
headerHeight: 30,
|
||||||
|
bodyTop: top + 30,
|
||||||
|
bodyHeight: bottom - top - 30,
|
||||||
|
bottom,
|
||||||
|
gutterWidth: 30,
|
||||||
|
contentWidth: 300,
|
||||||
|
rowStart: 0,
|
||||||
|
rowEnd: 0,
|
||||||
|
isCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { DiffFileSection } from "./types";
|
||||||
|
|
||||||
|
export interface VisibleDiffFileSections {
|
||||||
|
files: DiffFileSection[];
|
||||||
|
sticky: { file: DiffFileSection; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffInteractionWindowTop(scrollTop: number, viewportHeight: number): number {
|
||||||
|
"worklet";
|
||||||
|
const bucketHeight = Math.max(1, viewportHeight * 2);
|
||||||
|
return Math.floor(Math.max(0, scrollTop) / bucketHeight) * bucketHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffMaterializationWindow(
|
||||||
|
windowTop: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
): { top: number; height: number } {
|
||||||
|
return {
|
||||||
|
top: Math.max(0, windowTop - viewportHeight * 2),
|
||||||
|
height: viewportHeight * 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffHeaderViewportKey(input: {
|
||||||
|
files: readonly DiffFileSection[];
|
||||||
|
scrollTop: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
activePath: string | null;
|
||||||
|
}): string {
|
||||||
|
const window = resolveVisibleFileSections({ ...input, overscan: 0 });
|
||||||
|
const headers = window.files
|
||||||
|
.filter((file) => file !== window.sticky?.file)
|
||||||
|
.map((file) => `${file.fileIndex}:${file.top - input.scrollTop}`);
|
||||||
|
if (window.sticky) headers.push(`${window.sticky.file.fileIndex}:${window.sticky.y}`);
|
||||||
|
return `${input.activePath ?? ""}|${headers.join(",")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleFileSections(input: {
|
||||||
|
files: readonly DiffFileSection[];
|
||||||
|
scrollTop: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
overscan: number;
|
||||||
|
}): VisibleDiffFileSections {
|
||||||
|
if (input.files.length === 0 || input.viewportHeight <= 0) {
|
||||||
|
return { files: [], sticky: null };
|
||||||
|
}
|
||||||
|
const visibleTop = Math.max(0, input.scrollTop - input.overscan);
|
||||||
|
const visibleBottom = input.scrollTop + input.viewportHeight + input.overscan;
|
||||||
|
const start = firstFileWhoseEndExceeds(input.files, visibleTop);
|
||||||
|
const files: DiffFileSection[] = [];
|
||||||
|
for (let index = start; index < input.files.length; index += 1) {
|
||||||
|
const file = input.files[index];
|
||||||
|
if (!file || file.top >= visibleBottom) break;
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
const stickyFile = input.files[firstFileWhoseEndExceeds(input.files, input.scrollTop)] ?? null;
|
||||||
|
const sticky = stickyFile
|
||||||
|
? {
|
||||||
|
file: stickyFile,
|
||||||
|
y: Math.min(0, stickyFile.bottom - input.scrollTop - stickyFile.headerHeight),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return { files, sticky };
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstFileWhoseEndExceeds(files: readonly DiffFileSection[], offset: number): number {
|
||||||
|
let low = 0;
|
||||||
|
let high = files.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >>> 1;
|
||||||
|
const file = files[middle];
|
||||||
|
if (file && file.bottom <= offset) low = middle + 1;
|
||||||
|
else high = middle;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
@@ -147,6 +147,11 @@ function buildModel(changedContent: string, overrides: Partial<BuildDiffDocument
|
|||||||
deletionBackground: "#100",
|
deletionBackground: "#100",
|
||||||
emptyBackground: "#111",
|
emptyBackground: "#111",
|
||||||
selection: "blue",
|
selection: "blue",
|
||||||
|
headerActiveSurface: "#222",
|
||||||
|
headerBorder: "#333",
|
||||||
|
statusSuccess: "green",
|
||||||
|
statusDanger: "red",
|
||||||
|
statusWarning: "orange",
|
||||||
syntax: {},
|
syntax: {},
|
||||||
},
|
},
|
||||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { withUnistyles } from "react-native-unistyles";
|
|||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { createDiffPalette, retainDiffPalette } from "./palette";
|
import { createDiffPalette, retainDiffPalette } from "./palette";
|
||||||
import { DiffSurface } from "./surface";
|
import { DiffSurface } from "./surface";
|
||||||
import type { DiffDocumentProps, DiffPalette } from "./types";
|
import type { DiffDocumentProps, DiffHeaderTypography, DiffPalette } from "./types";
|
||||||
|
|
||||||
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
|
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
|
||||||
|
|
||||||
type ThemedDiffDocumentProps = DiffDocumentProps & {
|
type ThemedDiffDocumentProps = DiffDocumentProps & {
|
||||||
palette: DiffPalette;
|
palette: DiffPalette;
|
||||||
|
headerTypography: DiffHeaderTypography;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_PATHS: string[] = [];
|
const EMPTY_PATHS: string[] = [];
|
||||||
@@ -45,6 +46,11 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
|||||||
|
|
||||||
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
||||||
palette: createDiffPalette(theme),
|
palette: createDiffPalette(theme),
|
||||||
|
headerTypography: {
|
||||||
|
family: theme.fontFamily.ui,
|
||||||
|
size: theme.fontSize.base,
|
||||||
|
statSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function DiffDocument(props: DiffDocumentProps) {
|
export function DiffDocument(props: DiffDocumentProps) {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number })
|
|||||||
deletionBackground: "#100",
|
deletionBackground: "#100",
|
||||||
emptyBackground: "#111",
|
emptyBackground: "#111",
|
||||||
selection: "blue",
|
selection: "blue",
|
||||||
|
headerActiveSurface: "#222",
|
||||||
|
headerBorder: "#333",
|
||||||
|
statusSuccess: "green",
|
||||||
|
statusDanger: "red",
|
||||||
|
statusWarning: "orange",
|
||||||
syntax: {},
|
syntax: {},
|
||||||
},
|
},
|
||||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
|||||||
deletionBackground: "#100",
|
deletionBackground: "#100",
|
||||||
emptyBackground: "#111",
|
emptyBackground: "#111",
|
||||||
selection: "blue",
|
selection: "blue",
|
||||||
|
headerActiveSurface: "#222",
|
||||||
|
headerBorder: "#333",
|
||||||
|
statusSuccess: "green",
|
||||||
|
statusDanger: "red",
|
||||||
|
statusWarning: "orange",
|
||||||
syntax: {},
|
syntax: {},
|
||||||
},
|
},
|
||||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||||
@@ -446,8 +451,108 @@ describe("diff document model", () => {
|
|||||||
expect(model.files[0]?.contentWidth).toBe(464);
|
expect(model.files[0]?.contentWidth).toBe(464);
|
||||||
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(224);
|
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(224);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds exact unwrapped scroll geometry without measuring offscreen files", () => {
|
||||||
|
const files = [file("a.ts"), file("b.ts"), file("c.ts")];
|
||||||
|
let measurementCount = 0;
|
||||||
|
const countingMeasurer: TextMeasurer = {
|
||||||
|
measure(text) {
|
||||||
|
measurementCount += 1;
|
||||||
|
return Array.from(text).length * 10;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const eager = buildDiffDocumentModel(input({ files, wrapLines: false }));
|
||||||
|
const firstFileWindow = buildDiffDocumentModel(
|
||||||
|
input({
|
||||||
|
files,
|
||||||
|
wrapLines: false,
|
||||||
|
measureText: countingMeasurer,
|
||||||
|
materializationWindow: { top: 0, height: eager.files[0]!.bottom },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstFileWindow.height).toBe(eager.height);
|
||||||
|
expect(firstFileWindow.files.map((entry) => [entry.top, entry.bottom])).toEqual(
|
||||||
|
eager.files.map((entry) => [entry.top, entry.bottom]),
|
||||||
|
);
|
||||||
|
expect(firstFileWindow.rows[firstFileWindow.files[0]!.rowStart]).toMatchObject({
|
||||||
|
kind: "line",
|
||||||
|
});
|
||||||
|
expect(firstFileWindow.rows[firstFileWindow.files[1]!.rowStart]).toMatchObject({
|
||||||
|
kind: "line",
|
||||||
|
});
|
||||||
|
expect(measuredFragments(firstFileWindow, "a.ts")).toBeGreaterThan(0);
|
||||||
|
expect(measuredFragments(firstFileWindow, "b.ts")).toBe(0);
|
||||||
|
expect(measuredFragments(firstFileWindow, "c.ts")).toBe(0);
|
||||||
|
expect(measurementCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains measured files while materializing a later unwrapped window", () => {
|
||||||
|
const files = [file("a.ts"), file("b.ts"), file("c.ts")];
|
||||||
|
const eager = buildDiffDocumentModel(input({ files, wrapLines: false }));
|
||||||
|
const first = buildDiffDocumentModel(
|
||||||
|
input({
|
||||||
|
files,
|
||||||
|
wrapLines: false,
|
||||||
|
materializationWindow: { top: 0, height: eager.files[0]!.bottom },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lastFile = eager.files[2]!;
|
||||||
|
const last = buildDiffDocumentModel(
|
||||||
|
input({
|
||||||
|
files,
|
||||||
|
wrapLines: false,
|
||||||
|
materializationWindow: { top: lastFile.top, height: lastFile.bottom - lastFile.top },
|
||||||
|
reuseFrom: [first],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(measuredFragments(last, "a.ts")).toBeGreaterThan(0);
|
||||||
|
expect(measuredFragments(last, "b.ts")).toBe(0);
|
||||||
|
expect(measuredFragments(last, "c.ts")).toBeGreaterThan(0);
|
||||||
|
expect(last.height).toBe(eager.height);
|
||||||
|
expect(firstLine(last, "a.ts")).toBe(firstLine(first, "a.ts"));
|
||||||
|
expect(firstLine(last, "b.ts")).toBe(firstLine(first, "b.ts"));
|
||||||
|
expect(firstLineCells(last, "a.ts")).toBe(firstLineCells(first, "a.ts"));
|
||||||
|
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps wrapped geometry fully measured", () => {
|
||||||
|
const model = buildDiffDocumentModel(
|
||||||
|
input({
|
||||||
|
files: [file("a.ts"), file("b.ts")],
|
||||||
|
wrapLines: true,
|
||||||
|
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
|
||||||
|
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function measuredFragments(model: ReturnType<typeof buildDiffDocumentModel>, path: string): number {
|
||||||
|
const fileSection = model.files.find((entry) => entry.path === path);
|
||||||
|
if (!fileSection) throw new Error(`Expected ${path}`);
|
||||||
|
return model.rows
|
||||||
|
.slice(fileSection.rowStart, fileSection.rowEnd)
|
||||||
|
.filter((row) => row.kind === "line")
|
||||||
|
.flatMap((row) => row.cells)
|
||||||
|
.reduce((count, cell) => count + (cell?.fragments.length ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLineCells(model: ReturnType<typeof buildDiffDocumentModel>, path: string) {
|
||||||
|
return firstLine(model, path).cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(model: ReturnType<typeof buildDiffDocumentModel>, path: string) {
|
||||||
|
const fileSection = model.files.find((entry) => entry.path === path);
|
||||||
|
if (!fileSection) throw new Error(`Expected ${path}`);
|
||||||
|
const row = model.rows[fileSection.rowStart];
|
||||||
|
if (!row || row.kind !== "line") throw new Error(`Expected a line in ${path}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
function shapingMeasurer(measure: (text: string) => number): TextMeasurer {
|
function shapingMeasurer(measure: (text: string) => number): TextMeasurer {
|
||||||
return {
|
return {
|
||||||
measure,
|
measure,
|
||||||
|
|||||||
@@ -37,9 +37,33 @@ interface CellSource {
|
|||||||
sourceIdentity: DiffSourceIdentity;
|
sourceIdentity: DiffSourceIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeometryLine {
|
||||||
|
cells: DiffLineRow["cells"];
|
||||||
|
reviewHeight: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReusableModelIndex {
|
||||||
|
model: DiffDocumentModel;
|
||||||
|
filesBySource: Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>;
|
||||||
|
measuredFiles: Set<DiffFileSection>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReusableFileRows {
|
||||||
|
model: DiffDocumentModel;
|
||||||
|
file: DiffFileSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileRowsResult {
|
||||||
|
bottom: number;
|
||||||
|
maximumHorizontalOverflow: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
|
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
|
||||||
const rows: DiffRow[] = [];
|
const rows: DiffRow[] = [];
|
||||||
const files: DiffFileSection[] = [];
|
const files: DiffFileSection[] = [];
|
||||||
|
const reusableModels = indexReusableModels(input.reuseFrom);
|
||||||
|
const currentReviewGeometryKey = reviewGeometryKey(input.reviewActions);
|
||||||
let documentTop = 0;
|
let documentTop = 0;
|
||||||
|
|
||||||
for (const [fileIndex, file] of input.files.entries()) {
|
for (const [fileIndex, file] of input.files.entries()) {
|
||||||
@@ -51,96 +75,40 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
|||||||
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
||||||
let maximumHorizontalOverflow = 0;
|
let maximumHorizontalOverflow = 0;
|
||||||
|
|
||||||
const reusableModel = input.reuseFrom?.find((candidate) =>
|
const reusable = findReusableFileRows({
|
||||||
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed),
|
indexes: reusableModels,
|
||||||
);
|
buildInput: input,
|
||||||
const reusableFile = reusableModel?.files.find(
|
sourceFile: file,
|
||||||
(candidate) => candidate.file === file && !candidate.isCollapsed,
|
fileTop,
|
||||||
);
|
bodyTop,
|
||||||
|
});
|
||||||
|
const reusableModel = reusable?.model;
|
||||||
|
const reusableFile = reusable?.file;
|
||||||
|
|
||||||
if (!isCollapsed && reusableFile) {
|
if (!isCollapsed) {
|
||||||
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
|
const result =
|
||||||
for (const reusedRow of reusedRows) {
|
reusableModel && reusableFile
|
||||||
if (reusedRow.kind === "line") {
|
? appendReusableFileRows({
|
||||||
const reviewHeight = reviewHeightForCells(reusedRow.cells, input);
|
rows,
|
||||||
const textHeight = reusedRow.height - reusedRow.reviewHeight;
|
|
||||||
const height = textHeight + reviewHeight;
|
|
||||||
rows.push({
|
|
||||||
...reusedRow,
|
|
||||||
index: rows.length,
|
|
||||||
fileIndex,
|
|
||||||
top: documentTop,
|
|
||||||
height,
|
|
||||||
reviewHeight,
|
|
||||||
});
|
|
||||||
documentTop += height;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
rows.push({
|
|
||||||
...reusedRow,
|
|
||||||
index: rows.length,
|
|
||||||
fileIndex,
|
|
||||||
top: documentTop,
|
|
||||||
});
|
|
||||||
documentTop += reusedRow.height;
|
|
||||||
}
|
|
||||||
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
|
|
||||||
documentTop += DIFF_BODY_BORDER_HEIGHT;
|
|
||||||
} else if (!isCollapsed) {
|
|
||||||
if (file.status === "binary" || file.status === "too_large") {
|
|
||||||
const height = input.typography.lineHeight + 24;
|
|
||||||
rows.push({
|
|
||||||
kind: "status",
|
|
||||||
index: rows.length,
|
|
||||||
fileIndex,
|
|
||||||
path: file.path,
|
|
||||||
top: documentTop,
|
|
||||||
height,
|
|
||||||
label: file.status === "binary" ? input.labels.binary : input.labels.tooLarge,
|
|
||||||
});
|
|
||||||
documentTop += height;
|
|
||||||
} else {
|
|
||||||
const sources = lineSources(file, input.layout);
|
|
||||||
for (const sourceCells of sources) {
|
|
||||||
const columnCount = sourceCells.length;
|
|
||||||
const columnWidth = input.viewportWidth / columnCount;
|
|
||||||
const availableWidth = Math.max(
|
|
||||||
input.measureText.measure("M"),
|
|
||||||
columnWidth - gutterWidth - CODE_HORIZONTAL_PADDING,
|
|
||||||
);
|
|
||||||
const cells = sourceCells.map((source) => {
|
|
||||||
if (!source) return null;
|
|
||||||
const cell = measureCell({
|
|
||||||
source,
|
|
||||||
availableWidth,
|
|
||||||
input,
|
input,
|
||||||
|
model: reusableModel,
|
||||||
|
file: reusableFile,
|
||||||
|
fileIndex,
|
||||||
|
fileTop,
|
||||||
|
bodyTop,
|
||||||
|
reviewGeometryKey: currentReviewGeometryKey,
|
||||||
|
})
|
||||||
|
: appendNewFileRows({
|
||||||
|
rows,
|
||||||
|
input,
|
||||||
|
file,
|
||||||
|
fileIndex,
|
||||||
|
fileTop,
|
||||||
|
bodyTop,
|
||||||
|
gutterWidth,
|
||||||
});
|
});
|
||||||
maximumHorizontalOverflow = Math.max(
|
documentTop = result.bottom;
|
||||||
maximumHorizontalOverflow,
|
maximumHorizontalOverflow = result.maximumHorizontalOverflow;
|
||||||
gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
|
||||||
);
|
|
||||||
return cell;
|
|
||||||
}) as DiffLineRow["cells"];
|
|
||||||
const textHeight = Math.max(
|
|
||||||
input.typography.lineHeight,
|
|
||||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * input.typography.lineHeight),
|
|
||||||
);
|
|
||||||
const reviewHeight = reviewHeightForCells(cells, input);
|
|
||||||
const height = textHeight + reviewHeight;
|
|
||||||
rows.push({
|
|
||||||
kind: "line",
|
|
||||||
index: rows.length,
|
|
||||||
fileIndex,
|
|
||||||
path: file.path,
|
|
||||||
top: documentTop,
|
|
||||||
height,
|
|
||||||
cells,
|
|
||||||
reviewHeight,
|
|
||||||
});
|
|
||||||
documentTop += height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
documentTop += DIFF_BODY_BORDER_HEIGHT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
files.push({
|
files.push({
|
||||||
@@ -170,9 +138,193 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
|||||||
layout: input.layout,
|
layout: input.layout,
|
||||||
wrapLines: input.wrapLines,
|
wrapLines: input.wrapLines,
|
||||||
viewportWidth: input.viewportWidth,
|
viewportWidth: input.viewportWidth,
|
||||||
|
reviewGeometryKey: currentReviewGeometryKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendReusableFileRows(candidate: {
|
||||||
|
rows: DiffRow[];
|
||||||
|
input: BuildDiffDocumentModelInput;
|
||||||
|
model: DiffDocumentModel;
|
||||||
|
file: DiffFileSection;
|
||||||
|
fileIndex: number;
|
||||||
|
fileTop: number;
|
||||||
|
bodyTop: number;
|
||||||
|
reviewGeometryKey: string;
|
||||||
|
}): FileRowsResult {
|
||||||
|
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
||||||
|
const retainsRowGeometry =
|
||||||
|
!candidate.input.wrapLines &&
|
||||||
|
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||||
|
candidate.file.top === candidate.fileTop &&
|
||||||
|
candidate.file.rowStart === candidate.rows.length;
|
||||||
|
if (retainsRowGeometry) {
|
||||||
|
candidate.rows.push(...reusedRows);
|
||||||
|
return {
|
||||||
|
bottom: candidate.bodyTop + candidate.file.bodyHeight,
|
||||||
|
maximumHorizontalOverflow: Math.max(
|
||||||
|
0,
|
||||||
|
candidate.file.contentWidth - candidate.input.viewportWidth,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let top = candidate.bodyTop;
|
||||||
|
for (const reusedRow of reusedRows) {
|
||||||
|
const nextRow = repositionReusedRow(reusedRow, candidate, top);
|
||||||
|
candidate.rows.push(nextRow);
|
||||||
|
top += nextRow.height;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bottom: top + DIFF_BODY_BORDER_HEIGHT,
|
||||||
|
maximumHorizontalOverflow: Math.max(
|
||||||
|
0,
|
||||||
|
candidate.file.contentWidth - candidate.input.viewportWidth,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositionReusedRow(
|
||||||
|
row: DiffRow,
|
||||||
|
candidate: Pick<Parameters<typeof appendReusableFileRows>[0], "rows" | "input" | "fileIndex">,
|
||||||
|
top: number,
|
||||||
|
): DiffRow {
|
||||||
|
if (row.kind !== "line") {
|
||||||
|
return { ...row, index: candidate.rows.length, fileIndex: candidate.fileIndex, top };
|
||||||
|
}
|
||||||
|
const reviewHeight = reviewHeightForCells(row.cells, candidate.input);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
index: candidate.rows.length,
|
||||||
|
fileIndex: candidate.fileIndex,
|
||||||
|
top,
|
||||||
|
height: row.height - row.reviewHeight + reviewHeight,
|
||||||
|
reviewHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendNewFileRows(candidate: {
|
||||||
|
rows: DiffRow[];
|
||||||
|
input: BuildDiffDocumentModelInput;
|
||||||
|
file: BuildDiffDocumentModelInput["files"][number];
|
||||||
|
fileIndex: number;
|
||||||
|
fileTop: number;
|
||||||
|
bodyTop: number;
|
||||||
|
gutterWidth: number;
|
||||||
|
}): FileRowsResult {
|
||||||
|
if (candidate.file.status === "binary" || candidate.file.status === "too_large") {
|
||||||
|
const height = candidate.input.typography.lineHeight + 24;
|
||||||
|
candidate.rows.push({
|
||||||
|
kind: "status",
|
||||||
|
index: candidate.rows.length,
|
||||||
|
fileIndex: candidate.fileIndex,
|
||||||
|
path: candidate.file.path,
|
||||||
|
top: candidate.bodyTop,
|
||||||
|
height,
|
||||||
|
label:
|
||||||
|
candidate.file.status === "binary"
|
||||||
|
? candidate.input.labels.binary
|
||||||
|
: candidate.input.labels.tooLarge,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bottom: candidate.bodyTop + height + DIFF_BODY_BORDER_HEIGHT,
|
||||||
|
maximumHorizontalOverflow: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deferredLines =
|
||||||
|
candidate.input.materializationWindow && !candidate.input.wrapLines
|
||||||
|
? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input)
|
||||||
|
: null;
|
||||||
|
const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0;
|
||||||
|
const shouldMaterialize =
|
||||||
|
deferredLines === null ||
|
||||||
|
intersectsMaterializationWindow(
|
||||||
|
candidate.input,
|
||||||
|
candidate.fileTop,
|
||||||
|
candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT,
|
||||||
|
);
|
||||||
|
return shouldMaterialize
|
||||||
|
? appendMeasuredFileRows(candidate)
|
||||||
|
: appendGeometryFileRows(candidate, deferredLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendGeometryFileRows(
|
||||||
|
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||||
|
lines: readonly GeometryLine[],
|
||||||
|
): FileRowsResult {
|
||||||
|
let top = candidate.bodyTop;
|
||||||
|
for (const line of lines) {
|
||||||
|
candidate.rows.push({
|
||||||
|
kind: "line",
|
||||||
|
index: candidate.rows.length,
|
||||||
|
fileIndex: candidate.fileIndex,
|
||||||
|
path: candidate.file.path,
|
||||||
|
top,
|
||||||
|
height: line.height,
|
||||||
|
cells: line.cells,
|
||||||
|
reviewHeight: line.reviewHeight,
|
||||||
|
});
|
||||||
|
top += line.height;
|
||||||
|
}
|
||||||
|
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMeasuredFileRows(
|
||||||
|
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||||
|
): FileRowsResult {
|
||||||
|
let top = candidate.bodyTop;
|
||||||
|
let maximumHorizontalOverflow = 0;
|
||||||
|
for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) {
|
||||||
|
const columnCount = sourceCells.length;
|
||||||
|
const columnWidth = candidate.input.viewportWidth / columnCount;
|
||||||
|
const availableWidth = Math.max(
|
||||||
|
candidate.input.measureText.measure("M"),
|
||||||
|
columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||||
|
);
|
||||||
|
const cells = sourceCells.map((source) => {
|
||||||
|
if (!source) return null;
|
||||||
|
const cell = measureCell({ source, availableWidth, input: candidate.input });
|
||||||
|
maximumHorizontalOverflow = Math.max(
|
||||||
|
maximumHorizontalOverflow,
|
||||||
|
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
||||||
|
);
|
||||||
|
return cell;
|
||||||
|
}) as DiffLineRow["cells"];
|
||||||
|
const textHeight = Math.max(
|
||||||
|
candidate.input.typography.lineHeight,
|
||||||
|
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||||
|
);
|
||||||
|
const reviewHeight = reviewHeightForCells(cells, candidate.input);
|
||||||
|
const height = textHeight + reviewHeight;
|
||||||
|
candidate.rows.push({
|
||||||
|
kind: "line",
|
||||||
|
index: candidate.rows.length,
|
||||||
|
fileIndex: candidate.fileIndex,
|
||||||
|
path: candidate.file.path,
|
||||||
|
top,
|
||||||
|
height,
|
||||||
|
cells,
|
||||||
|
reviewHeight,
|
||||||
|
});
|
||||||
|
top += height;
|
||||||
|
}
|
||||||
|
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewGeometryKey(
|
||||||
|
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
|
||||||
|
): string {
|
||||||
|
if (!reviewActions) return "";
|
||||||
|
const comments = [...reviewActions.commentsByTarget.entries()]
|
||||||
|
.map(([target, targetComments]) => [target, targetComments.map((comment) => comment.id).sort()])
|
||||||
|
.sort(([left], [right]) => String(left).localeCompare(String(right)));
|
||||||
|
const editor = reviewActions.editor
|
||||||
|
? [reviewActions.editor.target.key, reviewActions.editor.commentId]
|
||||||
|
: null;
|
||||||
|
return JSON.stringify([comments, editor]);
|
||||||
|
}
|
||||||
|
|
||||||
export function expandedBodyBorderTop(file: DiffFileSection): number | null {
|
export function expandedBodyBorderTop(file: DiffFileSection): number | null {
|
||||||
"worklet";
|
"worklet";
|
||||||
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
|
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
|
||||||
@@ -183,7 +335,7 @@ export function retainReusableModels(
|
|||||||
next: DiffDocumentModel,
|
next: DiffDocumentModel,
|
||||||
): DiffDocumentModel[] {
|
): DiffDocumentModel[] {
|
||||||
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
||||||
candidate.rows.length > best.rows.length ? candidate : best,
|
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
|
||||||
);
|
);
|
||||||
return fullest === next ? [next] : [fullest, next];
|
return fullest === next ? [next] : [fullest, next];
|
||||||
}
|
}
|
||||||
@@ -191,13 +343,14 @@ export function retainReusableModels(
|
|||||||
function lineSources(
|
function lineSources(
|
||||||
file: BuildDiffDocumentModelInput["files"][number],
|
file: BuildDiffDocumentModelInput["files"][number],
|
||||||
layout: "unified" | "split",
|
layout: "unified" | "split",
|
||||||
|
includeTokens: boolean,
|
||||||
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
|
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
|
||||||
if (layout === "split") {
|
if (layout === "split") {
|
||||||
return buildSplitDiffRows(file).map((row) => {
|
return buildSplitDiffRows(file).map((row) => {
|
||||||
if (row.kind === "header") {
|
if (row.kind === "header") {
|
||||||
return [headerSource(row.content, row.hunkIndex, row.lineIndex)];
|
return [headerSource(row.content, row.hunkIndex, row.lineIndex)];
|
||||||
}
|
}
|
||||||
return [cellSource(row.left), cellSource(row.right)];
|
return [cellSource(row.left, includeTokens), cellSource(row.right, includeTokens)];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return buildUnifiedDiffLines(file).map((entry) => [
|
return buildUnifiedDiffLines(file).map((entry) => [
|
||||||
@@ -206,7 +359,7 @@ function lineSources(
|
|||||||
content: entry.line.content,
|
content: entry.line.content,
|
||||||
lineNumber: entry.lineNumber,
|
lineNumber: entry.lineNumber,
|
||||||
reviewTarget: entry.reviewTarget,
|
reviewTarget: entry.reviewTarget,
|
||||||
tokenText: compactHighlightTokens(entry.line.tokens ?? []),
|
tokenText: includeTokens ? compactHighlightTokens(entry.line.tokens ?? []) : [],
|
||||||
sourceIdentity: {
|
sourceIdentity: {
|
||||||
hunkIndex: entry.hunkIndex,
|
hunkIndex: entry.hunkIndex,
|
||||||
lineIndex: entry.lineIndex,
|
lineIndex: entry.lineIndex,
|
||||||
@@ -227,14 +380,14 @@ function headerSource(content: string, hunkIndex: number, lineIndex: number): Ce
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
|
function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): CellSource | null {
|
||||||
if (!line) return null;
|
if (!line) return null;
|
||||||
return {
|
return {
|
||||||
type: line.type,
|
type: line.type,
|
||||||
content: line.content,
|
content: line.content,
|
||||||
lineNumber: line.lineNumber,
|
lineNumber: line.lineNumber,
|
||||||
reviewTarget: line.reviewTarget,
|
reviewTarget: line.reviewTarget,
|
||||||
tokenText: compactHighlightTokens(line.tokens ?? []),
|
tokenText: includeTokens ? compactHighlightTokens(line.tokens ?? []) : [],
|
||||||
sourceIdentity: {
|
sourceIdentity: {
|
||||||
hunkIndex: line.hunkIndex,
|
hunkIndex: line.hunkIndex,
|
||||||
lineIndex: line.lineIndex,
|
lineIndex: line.lineIndex,
|
||||||
@@ -243,6 +396,112 @@ function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function geometryCell(source: CellSource): DiffCell {
|
||||||
|
return {
|
||||||
|
type: source.type,
|
||||||
|
content: source.content,
|
||||||
|
lineNumber: source.lineNumber,
|
||||||
|
tokens: [],
|
||||||
|
fragments: [],
|
||||||
|
reviewTarget: source.reviewTarget,
|
||||||
|
sourceIdentity: source.sourceIdentity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryLines(
|
||||||
|
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
||||||
|
input: BuildDiffDocumentModelInput,
|
||||||
|
): GeometryLine[] {
|
||||||
|
return sources.map((sourceCells) => {
|
||||||
|
const cells = sourceCells.map((source) =>
|
||||||
|
source ? geometryCell(source) : null,
|
||||||
|
) as DiffLineRow["cells"];
|
||||||
|
const reviewHeight = reviewHeightForCells(cells, input);
|
||||||
|
return {
|
||||||
|
cells,
|
||||||
|
reviewHeight,
|
||||||
|
height: input.typography.lineHeight + reviewHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectsMaterializationWindow(
|
||||||
|
input: BuildDiffDocumentModelInput,
|
||||||
|
fileTop: number,
|
||||||
|
fileBottom: number,
|
||||||
|
): boolean {
|
||||||
|
const window = input.materializationWindow;
|
||||||
|
if (!window) return true;
|
||||||
|
return fileTop < window.top + window.height && fileBottom > window.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
|
||||||
|
if (file.isCollapsed) return false;
|
||||||
|
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
|
||||||
|
if (row.kind !== "line") return true;
|
||||||
|
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexReusableModels(
|
||||||
|
models: readonly DiffDocumentModel[] | undefined,
|
||||||
|
): ReusableModelIndex[] {
|
||||||
|
return (models ?? []).map((model) => {
|
||||||
|
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
|
||||||
|
const measuredFiles = new Set<DiffFileSection>();
|
||||||
|
for (const file of model.files) {
|
||||||
|
filesBySource.set(file.file, file);
|
||||||
|
if (hasMeasuredFileRows(model, file)) measuredFiles.add(file);
|
||||||
|
}
|
||||||
|
return { model, filesBySource, measuredFiles };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findReusableFileRows(candidate: {
|
||||||
|
indexes: readonly ReusableModelIndex[];
|
||||||
|
buildInput: BuildDiffDocumentModelInput;
|
||||||
|
sourceFile: BuildDiffDocumentModelInput["files"][number];
|
||||||
|
fileTop: number;
|
||||||
|
bodyTop: number;
|
||||||
|
}): ReusableFileRows | null {
|
||||||
|
for (const index of candidate.indexes) {
|
||||||
|
const file = index.filesBySource.get(candidate.sourceFile);
|
||||||
|
if (
|
||||||
|
file &&
|
||||||
|
canReuseFileRows({
|
||||||
|
buildInput: candidate.buildInput,
|
||||||
|
file,
|
||||||
|
isMeasured: index.measuredFiles.has(file),
|
||||||
|
fileTop: candidate.fileTop,
|
||||||
|
bodyTop: candidate.bodyTop,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return { model: index.model, file };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReuseFileRows(candidate: {
|
||||||
|
buildInput: BuildDiffDocumentModelInput;
|
||||||
|
file: DiffFileSection;
|
||||||
|
isMeasured: boolean;
|
||||||
|
fileTop: number;
|
||||||
|
bodyTop: number;
|
||||||
|
}): boolean {
|
||||||
|
if (candidate.file.isCollapsed) return false;
|
||||||
|
if (candidate.isMeasured) return true;
|
||||||
|
return !intersectsMaterializationWindow(
|
||||||
|
candidate.buildInput,
|
||||||
|
candidate.fileTop,
|
||||||
|
candidate.bodyTop + candidate.file.bodyHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function measuredFileCount(model: DiffDocumentModel): number {
|
||||||
|
return model.files.filter((file) => hasMeasuredFileRows(model, file)).length;
|
||||||
|
}
|
||||||
|
|
||||||
function measureCell(input: {
|
function measureCell(input: {
|
||||||
source: CellSource;
|
source: CellSource;
|
||||||
availableWidth: number;
|
availableWidth: number;
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ function build(layout: "unified" | "split") {
|
|||||||
deletionBackground: "#100",
|
deletionBackground: "#100",
|
||||||
emptyBackground: "#111",
|
emptyBackground: "#111",
|
||||||
selection: "blue",
|
selection: "blue",
|
||||||
|
headerActiveSurface: "#222",
|
||||||
|
headerBorder: "#333",
|
||||||
|
statusSuccess: "green",
|
||||||
|
statusDanger: "red",
|
||||||
|
statusWarning: "orange",
|
||||||
syntax: {},
|
syntax: {},
|
||||||
},
|
},
|
||||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
nativeCanvasSlabsForViewport,
|
nativeCanvasSlabsForViewport,
|
||||||
nativeCanvasWindowBucket,
|
nativeCanvasWindowBucket,
|
||||||
nativeCanvasWindowTop,
|
nativeCanvasWindowTop,
|
||||||
nativeStickyHeaderIndices,
|
|
||||||
} from "./native-slabs";
|
} from "./native-slabs";
|
||||||
import type { DiffDocumentModel } from "./types";
|
import type { DiffDocumentModel } from "./types";
|
||||||
|
|
||||||
@@ -34,15 +33,11 @@ function modelWithBodies(
|
|||||||
layout: "unified",
|
layout: "unified",
|
||||||
wrapLines: false,
|
wrapLines: false,
|
||||||
viewportWidth: 400,
|
viewportWidth: 400,
|
||||||
|
reviewGeometryKey: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("native canvas slabs", () => {
|
describe("native canvas slabs", () => {
|
||||||
it("identifies each direct file header child for native sticky scrolling", () => {
|
|
||||||
expect(nativeStickyHeaderIndices(0)).toEqual([]);
|
|
||||||
expect(nativeStickyHeaderIndices(3)).toEqual([1, 3, 5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cuts large file bodies into fixed document-positioned slabs", () => {
|
it("cuts large file bodies into fixed document-positioned slabs", () => {
|
||||||
const slabs = buildNativeCanvasSlabs(
|
const slabs = buildNativeCanvasSlabs(
|
||||||
modelWithBodies([
|
modelWithBodies([
|
||||||
@@ -58,9 +53,11 @@ describe("native canvas slabs", () => {
|
|||||||
|
|
||||||
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
|
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
|
||||||
{ path: "small.ts", top: 30, height: 200 },
|
{ path: "small.ts", top: 30, height: 200 },
|
||||||
{ path: "large.ts", top: 260, height: 1200 },
|
{ path: "large.ts", top: 260, height: 768 },
|
||||||
{ path: "large.ts", top: 1460, height: 1200 },
|
{ path: "large.ts", top: 1028, height: 768 },
|
||||||
{ path: "large.ts", top: 2660, height: 800 },
|
{ path: "large.ts", top: 1796, height: 768 },
|
||||||
|
{ path: "large.ts", top: 2564, height: 768 },
|
||||||
|
{ path: "large.ts", top: 3332, height: 128 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +70,7 @@ describe("native canvas slabs", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
|
expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
|
||||||
30, 1230, 2430,
|
798, 1566, 2334,
|
||||||
]);
|
]);
|
||||||
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
|
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
|
||||||
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
|
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ export interface NativeCanvasSlab {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nativeStickyHeaderIndices(fileCount: number): number[] {
|
|
||||||
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildNativeCanvasSlabs(
|
export function buildNativeCanvasSlabs(
|
||||||
model: DiffDocumentModel,
|
model: DiffDocumentModel,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
): NativeCanvasSlab[] {
|
): NativeCanvasSlab[] {
|
||||||
const slabHeight = Math.max(768, viewportHeight * 2);
|
const slabHeight = Math.max(768, viewportHeight);
|
||||||
return model.files.flatMap((file) => {
|
return model.files.flatMap((file) => {
|
||||||
const slabs: NativeCanvasSlab[] = [];
|
const slabs: NativeCanvasSlab[] = [];
|
||||||
let slabIndex = 0;
|
let slabIndex = 0;
|
||||||
@@ -39,8 +35,8 @@ export function nativeCanvasSlabsForViewport(
|
|||||||
scrollTop: number,
|
scrollTop: number,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
): NativeCanvasSlab[] {
|
): NativeCanvasSlab[] {
|
||||||
const top = Math.max(0, scrollTop - viewportHeight);
|
const top = Math.max(0, scrollTop - viewportHeight / 2);
|
||||||
const bottom = scrollTop + viewportHeight * 2;
|
const bottom = scrollTop + viewportHeight * 1.5;
|
||||||
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
|
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
ClipOp,
|
ClipOp,
|
||||||
|
PaintStyle,
|
||||||
Skia,
|
Skia,
|
||||||
|
StrokeCap,
|
||||||
|
StrokeJoin,
|
||||||
type SkCanvas,
|
type SkCanvas,
|
||||||
type SkPaint,
|
type SkPaint,
|
||||||
type SkPicture,
|
type SkPicture,
|
||||||
} from "@shopify/react-native-skia";
|
} from "@shopify/react-native-skia";
|
||||||
|
import {
|
||||||
|
DIFF_FILE_HEADER_CONTENT_HEIGHT,
|
||||||
|
DIFF_FILE_HEADER_HEIGHT,
|
||||||
|
DIFF_FILE_HEADER_ICON_SIZE,
|
||||||
|
DIFF_FILE_HEADER_LEFT,
|
||||||
|
DIFF_FILE_HEADER_RIGHT,
|
||||||
|
DIFF_FILE_HEADER_TEXT_GAP,
|
||||||
|
allocateDiffHeaderTextWidths,
|
||||||
|
diffFileChangeKind,
|
||||||
|
directorySuffix,
|
||||||
|
fileNameForPath,
|
||||||
|
formatDiffCount,
|
||||||
|
} from "@/git/file-header-presentation";
|
||||||
import { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, visibleRowRange } from "./model";
|
import { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, visibleRowRange } from "./model";
|
||||||
import { nativeTextRuns } from "./native-text-runs";
|
import { nativeTextRuns } from "./native-text-runs";
|
||||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||||
import { codeLineNumberTone } from "./palette";
|
import { codeLineNumberTone } from "./palette";
|
||||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||||
import type { NativeTextLayout } from "./text.native";
|
import {
|
||||||
|
shapeNativeHeaderText,
|
||||||
|
type NativeHeaderTextLayout,
|
||||||
|
type NativeShapedHeaderText,
|
||||||
|
type NativeTextLayout,
|
||||||
|
} from "./text.native";
|
||||||
import type {
|
import type {
|
||||||
DiffCell,
|
DiffCell,
|
||||||
DiffDocumentModel,
|
DiffDocumentModel,
|
||||||
@@ -31,6 +52,10 @@ export interface NativePaints {
|
|||||||
additionBackground: SkPaint;
|
additionBackground: SkPaint;
|
||||||
deletionBackground: SkPaint;
|
deletionBackground: SkPaint;
|
||||||
headerSurface: SkPaint;
|
headerSurface: SkPaint;
|
||||||
|
headerBorder: SkPaint;
|
||||||
|
statusSuccess: SkPaint;
|
||||||
|
statusDanger: SkPaint;
|
||||||
|
statusWarning: SkPaint;
|
||||||
emptyBackground: SkPaint;
|
emptyBackground: SkPaint;
|
||||||
text: Record<string, SkPaint>;
|
text: Record<string, SkPaint>;
|
||||||
}
|
}
|
||||||
@@ -46,6 +71,10 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
|
|||||||
additionBackground: paint(palette.additionBackground),
|
additionBackground: paint(palette.additionBackground),
|
||||||
deletionBackground: paint(palette.deletionBackground),
|
deletionBackground: paint(palette.deletionBackground),
|
||||||
headerSurface: paint(palette.headerSurface),
|
headerSurface: paint(palette.headerSurface),
|
||||||
|
headerBorder: paint(palette.headerBorder),
|
||||||
|
statusSuccess: paint(palette.statusSuccess),
|
||||||
|
statusDanger: paint(palette.statusDanger),
|
||||||
|
statusWarning: paint(palette.statusWarning),
|
||||||
emptyBackground: paint(palette.emptyBackground),
|
emptyBackground: paint(palette.emptyBackground),
|
||||||
text: Object.fromEntries(
|
text: Object.fromEntries(
|
||||||
[
|
[
|
||||||
@@ -55,6 +84,156 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordNativeHeaderPicture(input: {
|
||||||
|
file: DiffFileSection;
|
||||||
|
viewportWidth: number;
|
||||||
|
textLayout: NativeHeaderTextLayout;
|
||||||
|
paints: NativePaints;
|
||||||
|
}): SkPicture {
|
||||||
|
const recorder = Skia.PictureRecorder();
|
||||||
|
const canvas = recorder.beginRecording(
|
||||||
|
Skia.XYWHRect(0, 0, input.viewportWidth, DIFF_FILE_HEADER_HEIGHT),
|
||||||
|
);
|
||||||
|
canvas.drawRect(
|
||||||
|
Skia.XYWHRect(0, DIFF_FILE_HEADER_HEIGHT - 1, input.viewportWidth, 1),
|
||||||
|
input.paints.headerBorder,
|
||||||
|
);
|
||||||
|
const iconX = input.viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
|
||||||
|
const additions = `+${formatDiffCount(input.file.file.additions)}`;
|
||||||
|
const deletions = `-${formatDiffCount(input.file.file.deletions)}`;
|
||||||
|
const additionsText = shapeNativeHeaderText({
|
||||||
|
layout: input.textLayout,
|
||||||
|
text: additions,
|
||||||
|
size: "stat",
|
||||||
|
tone: "statusSuccess",
|
||||||
|
});
|
||||||
|
const deletionsText = shapeNativeHeaderText({
|
||||||
|
layout: input.textLayout,
|
||||||
|
text: deletions,
|
||||||
|
size: "stat",
|
||||||
|
tone: "statusDanger",
|
||||||
|
});
|
||||||
|
const statX = iconX - 8 - additionsText.width - 4 - deletionsText.width;
|
||||||
|
paintNativeHeaderText(canvas, additionsText, statX);
|
||||||
|
paintNativeHeaderText(canvas, deletionsText, statX + additionsText.width + 4);
|
||||||
|
paintNativeChangeIcon(
|
||||||
|
canvas,
|
||||||
|
input.file,
|
||||||
|
iconX,
|
||||||
|
(DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
|
||||||
|
input.paints,
|
||||||
|
);
|
||||||
|
|
||||||
|
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
|
||||||
|
const name = fileNameForPath(input.file.path);
|
||||||
|
const directory = directorySuffix(input.file.path).trimStart();
|
||||||
|
const fitted = fitNativeHeaderText(input.textLayout, name, directory, available);
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(
|
||||||
|
Skia.XYWHRect(DIFF_FILE_HEADER_LEFT, 0, available, DIFF_FILE_HEADER_CONTENT_HEIGHT),
|
||||||
|
ClipOp.Intersect,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
paintNativeHeaderText(canvas, fitted.name, DIFF_FILE_HEADER_LEFT);
|
||||||
|
if (fitted.directory.width > 0) {
|
||||||
|
paintNativeHeaderText(
|
||||||
|
canvas,
|
||||||
|
fitted.directory,
|
||||||
|
DIFF_FILE_HEADER_LEFT + fitted.name.width + DIFF_FILE_HEADER_TEXT_GAP,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
|
const picture = recorder.finishRecordingAsPicture();
|
||||||
|
additionsText.paragraph.dispose();
|
||||||
|
deletionsText.paragraph.dispose();
|
||||||
|
fitted.name.paragraph.dispose();
|
||||||
|
fitted.directory.paragraph.dispose();
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintNativeHeaderText(canvas: SkCanvas, text: NativeShapedHeaderText, x: number): void {
|
||||||
|
text.paragraph.paint(canvas, x, (DIFF_FILE_HEADER_CONTENT_HEIGHT - text.height) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitNativeHeaderText(
|
||||||
|
layout: NativeHeaderTextLayout,
|
||||||
|
name: string,
|
||||||
|
directory: string,
|
||||||
|
available: number,
|
||||||
|
): { name: NativeShapedHeaderText; directory: NativeShapedHeaderText } {
|
||||||
|
let nameText = shapeNativeHeaderText({ layout, text: name, size: "body", tone: "foreground" });
|
||||||
|
let directoryText = shapeNativeHeaderText({
|
||||||
|
layout,
|
||||||
|
text: directory,
|
||||||
|
size: "body",
|
||||||
|
tone: "foregroundMuted",
|
||||||
|
});
|
||||||
|
if (nameText.width + (directory ? 4 + directoryText.width : 0) <= available) {
|
||||||
|
return { name: nameText, directory: directoryText };
|
||||||
|
}
|
||||||
|
|
||||||
|
const widths = allocateDiffHeaderTextWidths({
|
||||||
|
available,
|
||||||
|
nameWidth: nameText.width,
|
||||||
|
directoryWidth: directoryText.width,
|
||||||
|
});
|
||||||
|
if (widths.name < nameText.width) {
|
||||||
|
nameText.paragraph.dispose();
|
||||||
|
nameText = shapeNativeHeaderText({
|
||||||
|
layout,
|
||||||
|
text: name,
|
||||||
|
size: "body",
|
||||||
|
tone: "foreground",
|
||||||
|
maximumWidth: widths.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
directoryText.paragraph.dispose();
|
||||||
|
directoryText = shapeNativeHeaderText({
|
||||||
|
layout,
|
||||||
|
text: widths.directory > 0 ? directory : "",
|
||||||
|
size: "body",
|
||||||
|
tone: "foregroundMuted",
|
||||||
|
maximumWidth: widths.directory,
|
||||||
|
});
|
||||||
|
return { name: nameText, directory: directoryText };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintNativeChangeIcon(
|
||||||
|
canvas: SkCanvas,
|
||||||
|
file: DiffFileSection,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
paints: NativePaints,
|
||||||
|
): void {
|
||||||
|
const change = diffFileChangeKind(file.file);
|
||||||
|
let source = paints.statusWarning;
|
||||||
|
if (change === "added") source = paints.statusSuccess;
|
||||||
|
else if (change === "deleted") source = paints.statusDanger;
|
||||||
|
const stroke = source.copy();
|
||||||
|
stroke.setStyle(PaintStyle.Stroke);
|
||||||
|
stroke.setStrokeWidth(DIFF_FILE_HEADER_ICON_SIZE / 12);
|
||||||
|
stroke.setStrokeCap(StrokeCap.Round);
|
||||||
|
stroke.setStrokeJoin(StrokeJoin.Round);
|
||||||
|
const scale = DIFF_FILE_HEADER_ICON_SIZE / 24;
|
||||||
|
canvas.drawRRect(
|
||||||
|
Skia.RRectXY(
|
||||||
|
Skia.XYWHRect(x + 3 * scale, y + 3 * scale, 18 * scale, 18 * scale),
|
||||||
|
2 * scale,
|
||||||
|
2 * scale,
|
||||||
|
),
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
if (change === "added") {
|
||||||
|
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
|
||||||
|
canvas.drawLine(x + 12 * scale, y + 8 * scale, x + 12 * scale, y + 16 * scale, stroke);
|
||||||
|
} else if (change === "deleted") {
|
||||||
|
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
|
||||||
|
} else {
|
||||||
|
canvas.drawCircle(x + 12 * scale, y + 12 * scale, scale, stroke);
|
||||||
|
}
|
||||||
|
stroke.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
function paint(color: string): SkPaint {
|
function paint(color: string): SkPaint {
|
||||||
const result = Skia.Paint();
|
const result = Skia.Paint();
|
||||||
result.setColor(Skia.Color(color));
|
result.setColor(Skia.Color(color));
|
||||||
|
|||||||
@@ -1,8 +1,94 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { paintWebViewport } from "./paint.web";
|
import { paintWebHeaders, paintWebViewport } from "./paint.web";
|
||||||
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
|
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
|
||||||
|
|
||||||
describe("web diff text shaping", () => {
|
describe("web diff text shaping", () => {
|
||||||
|
it("matches the 30px file-header alignment rails exactly", () => {
|
||||||
|
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
|
||||||
|
const labels: Array<{ text: string; x: number; y: number; color: string; font: string }> = [];
|
||||||
|
let fillStyle = "";
|
||||||
|
let font = "";
|
||||||
|
const context = {
|
||||||
|
setTransform() {},
|
||||||
|
clearRect() {},
|
||||||
|
fillRect(x: number, y: number, width: number, height: number) {
|
||||||
|
fills.push({ color: fillStyle, x, y, width, height });
|
||||||
|
},
|
||||||
|
save() {},
|
||||||
|
restore() {},
|
||||||
|
beginPath() {},
|
||||||
|
rect() {},
|
||||||
|
clip() {},
|
||||||
|
translate() {},
|
||||||
|
scale() {},
|
||||||
|
roundRect() {},
|
||||||
|
moveTo() {},
|
||||||
|
lineTo() {},
|
||||||
|
stroke() {},
|
||||||
|
measureText(text: string) {
|
||||||
|
return {
|
||||||
|
width: text.length * 6,
|
||||||
|
actualBoundingBoxAscent: 8,
|
||||||
|
actualBoundingBoxDescent: 2,
|
||||||
|
} as TextMetrics;
|
||||||
|
},
|
||||||
|
fillText(text: string, x: number, y: number) {
|
||||||
|
labels.push({ text, x, y, color: fillStyle, font });
|
||||||
|
},
|
||||||
|
get fillStyle() {
|
||||||
|
return fillStyle;
|
||||||
|
},
|
||||||
|
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
|
||||||
|
fillStyle = String(value);
|
||||||
|
},
|
||||||
|
get font() {
|
||||||
|
return font;
|
||||||
|
},
|
||||||
|
set font(value: string) {
|
||||||
|
font = value;
|
||||||
|
},
|
||||||
|
strokeStyle: "",
|
||||||
|
lineWidth: 1,
|
||||||
|
lineCap: "butt",
|
||||||
|
lineJoin: "miter",
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
paintWebHeaders({
|
||||||
|
context,
|
||||||
|
model: {
|
||||||
|
...model,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
...model.files[0]!,
|
||||||
|
headerHeight: 30,
|
||||||
|
bodyTop: 30,
|
||||||
|
bodyHeight: 18,
|
||||||
|
bottom: 48,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
palette,
|
||||||
|
typography: { family: "system-ui", size: 14, statSize: 12 },
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportWidth: 200,
|
||||||
|
viewportHeight: 100,
|
||||||
|
devicePixelRatio: 1,
|
||||||
|
activePath: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fills.slice(0, 2)).toEqual([
|
||||||
|
{ color: "header", x: 0, y: 0, width: 200, height: 30 },
|
||||||
|
{ color: "header-border", x: 0, y: 29, width: 200, height: 1 },
|
||||||
|
]);
|
||||||
|
expect(labels).toEqual([
|
||||||
|
{ text: "+1", x: 142, y: 18, color: "success", font: "12px system-ui" },
|
||||||
|
{ text: "-0", x: 158, y: 18, color: "danger", font: "12px system-ui" },
|
||||||
|
{ text: "a.ts", x: 12, y: 18, color: "foreground", font: "14px system-ui" },
|
||||||
|
{ text: "src", x: 40, y: 18, color: "muted", font: "14px system-ui" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("paints syntax colors by clipping the complete measured shaped fragment", () => {
|
it("paints syntax colors by clipping the complete measured shaped fragment", () => {
|
||||||
const paintedText: string[] = [];
|
const paintedText: string[] = [];
|
||||||
const context = {
|
const context = {
|
||||||
@@ -314,6 +400,7 @@ function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
|
|||||||
layout,
|
layout,
|
||||||
wrapLines: false,
|
wrapLines: false,
|
||||||
viewportWidth: 200,
|
viewportWidth: 200,
|
||||||
|
reviewGeometryKey: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +443,11 @@ const palette: DiffPalette = {
|
|||||||
deletionBackground: "red-bg",
|
deletionBackground: "red-bg",
|
||||||
emptyBackground: "empty",
|
emptyBackground: "empty",
|
||||||
selection: "selection",
|
selection: "selection",
|
||||||
|
headerActiveSurface: "active-header",
|
||||||
|
headerBorder: "header-border",
|
||||||
|
statusSuccess: "success",
|
||||||
|
statusDanger: "danger",
|
||||||
|
statusWarning: "warning",
|
||||||
syntax: { first: "red", second: "blue" },
|
syntax: { first: "red", second: "blue" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,6 +519,7 @@ const model: DiffDocumentModel = {
|
|||||||
layout: "unified",
|
layout: "unified",
|
||||||
wrapLines: false,
|
wrapLines: false,
|
||||||
viewportWidth: 200,
|
viewportWidth: 200,
|
||||||
|
reviewGeometryKey: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const modelWithReview: DiffDocumentModel = {
|
const modelWithReview: DiffDocumentModel = {
|
||||||
|
|||||||
@@ -6,10 +6,26 @@ import {
|
|||||||
visibleRowRange,
|
visibleRowRange,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import { codeLineNumberTone, codeTextColor } from "./palette";
|
import { codeLineNumberTone, codeTextColor } from "./palette";
|
||||||
|
import {
|
||||||
|
DIFF_FILE_HEADER_CONTENT_HEIGHT,
|
||||||
|
DIFF_FILE_HEADER_HEIGHT,
|
||||||
|
DIFF_FILE_HEADER_ICON_SIZE,
|
||||||
|
DIFF_FILE_HEADER_LEFT,
|
||||||
|
DIFF_FILE_HEADER_RIGHT,
|
||||||
|
DIFF_FILE_HEADER_TEXT_GAP,
|
||||||
|
allocateDiffHeaderTextWidths,
|
||||||
|
diffFileChangeKind,
|
||||||
|
directorySuffix,
|
||||||
|
fileNameForPath,
|
||||||
|
formatDiffCount,
|
||||||
|
} from "@/git/file-header-presentation";
|
||||||
|
import { resolveVisibleFileSections } from "./header-layout";
|
||||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||||
import type {
|
import type {
|
||||||
DiffCell,
|
DiffCell,
|
||||||
DiffDocumentModel,
|
DiffDocumentModel,
|
||||||
|
DiffFileSection,
|
||||||
|
DiffHeaderTypography,
|
||||||
DiffLineRow,
|
DiffLineRow,
|
||||||
DiffPalette,
|
DiffPalette,
|
||||||
DiffSelection,
|
DiffSelection,
|
||||||
@@ -86,6 +102,167 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
|
|||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function paintWebHeaders(input: {
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
model: DiffDocumentModel;
|
||||||
|
palette: DiffPalette;
|
||||||
|
typography: DiffHeaderTypography;
|
||||||
|
scrollTop: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
devicePixelRatio: number;
|
||||||
|
activePath: string | null;
|
||||||
|
}): void {
|
||||||
|
const scale = input.devicePixelRatio;
|
||||||
|
input.context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
input.context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
|
||||||
|
const window = resolveVisibleFileSections({
|
||||||
|
files: input.model.files,
|
||||||
|
scrollTop: input.scrollTop,
|
||||||
|
viewportHeight: input.viewportHeight,
|
||||||
|
overscan: 0,
|
||||||
|
});
|
||||||
|
for (const file of window.files) {
|
||||||
|
if (file === window.sticky?.file) continue;
|
||||||
|
const y = file.top - input.scrollTop;
|
||||||
|
if (y + file.headerHeight <= 0 || y >= input.viewportHeight) continue;
|
||||||
|
paintWebHeader({ ...input, file, y });
|
||||||
|
}
|
||||||
|
if (window.sticky) paintWebHeader({ ...input, file: window.sticky.file, y: window.sticky.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintWebHeader(
|
||||||
|
input: Parameters<typeof paintWebHeaders>[0] & { file: DiffFileSection; y: number },
|
||||||
|
): void {
|
||||||
|
const { context, file, palette, typography, viewportWidth, y } = input;
|
||||||
|
context.fillStyle =
|
||||||
|
input.activePath === file.path ? palette.headerActiveSurface : palette.headerSurface;
|
||||||
|
context.fillRect(0, y, viewportWidth, DIFF_FILE_HEADER_HEIGHT);
|
||||||
|
context.fillStyle = palette.headerBorder;
|
||||||
|
context.fillRect(0, y + DIFF_FILE_HEADER_HEIGHT - 1, viewportWidth, 1);
|
||||||
|
|
||||||
|
const iconX = viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
|
||||||
|
const statLabels = [
|
||||||
|
`+${formatDiffCount(file.file.additions)}`,
|
||||||
|
`-${formatDiffCount(file.file.deletions)}`,
|
||||||
|
];
|
||||||
|
context.font = `${typography.statSize}px ${typography.family}`;
|
||||||
|
const statWidths = statLabels.map((label) => context.measureText(label).width);
|
||||||
|
const statWidth = statWidths[0]! + 4 + statWidths[1]!;
|
||||||
|
const statX = iconX - 8 - statWidth;
|
||||||
|
const statBaseline = centeredTextBaseline(context, y + DIFF_FILE_HEADER_CONTENT_HEIGHT / 2);
|
||||||
|
context.fillStyle = palette.statusSuccess;
|
||||||
|
context.fillText(statLabels[0]!, statX, statBaseline);
|
||||||
|
context.fillStyle = palette.statusDanger;
|
||||||
|
context.fillText(statLabels[1]!, statX + statWidths[0]! + 4, statBaseline);
|
||||||
|
paintChangeIcon(
|
||||||
|
context,
|
||||||
|
file,
|
||||||
|
iconX,
|
||||||
|
y + (DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
|
||||||
|
palette,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.font = `${typography.size}px ${typography.family}`;
|
||||||
|
const textBaseline = centeredTextBaseline(context, y + DIFF_FILE_HEADER_CONTENT_HEIGHT / 2);
|
||||||
|
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
|
||||||
|
const name = fileNameForPath(file.path);
|
||||||
|
// React Native Web collapses the leading separator space at the start of the
|
||||||
|
// directory Text node. Canvas does not, so paint only the visible glyphs.
|
||||||
|
const directory = directorySuffix(file.path).trimStart();
|
||||||
|
const fitted = fitHeaderText(context, name, directory, available);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(DIFF_FILE_HEADER_LEFT, y, available, DIFF_FILE_HEADER_CONTENT_HEIGHT);
|
||||||
|
context.clip();
|
||||||
|
context.fillStyle = palette.foreground;
|
||||||
|
context.fillText(fitted.name, DIFF_FILE_HEADER_LEFT, textBaseline);
|
||||||
|
if (fitted.directory) {
|
||||||
|
context.fillStyle = palette.foregroundMuted;
|
||||||
|
context.fillText(
|
||||||
|
fitted.directory,
|
||||||
|
DIFF_FILE_HEADER_LEFT + context.measureText(fitted.name).width + DIFF_FILE_HEADER_TEXT_GAP,
|
||||||
|
textBaseline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function centeredTextBaseline(context: CanvasRenderingContext2D, centerY: number): number {
|
||||||
|
const metrics = context.measureText("Mg");
|
||||||
|
return centerY + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitHeaderText(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
name: string,
|
||||||
|
directory: string,
|
||||||
|
available: number,
|
||||||
|
): { name: string; directory: string } {
|
||||||
|
const nameWidth = context.measureText(name).width;
|
||||||
|
const directoryWidth = context.measureText(directory).width;
|
||||||
|
if (nameWidth + (directory ? 4 + directoryWidth : 0) <= available) return { name, directory };
|
||||||
|
const widths = allocateDiffHeaderTextWidths({ available, nameWidth, directoryWidth });
|
||||||
|
return {
|
||||||
|
name: truncateCanvasText(context, name, widths.name),
|
||||||
|
directory: truncateCanvasText(context, directory, widths.directory),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateCanvasText(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
maximumWidth: number,
|
||||||
|
): string {
|
||||||
|
if (context.measureText(text).width <= maximumWidth) return text;
|
||||||
|
const ellipsis = "…";
|
||||||
|
if (context.measureText(ellipsis).width > maximumWidth) return "";
|
||||||
|
let low = 0;
|
||||||
|
let high = text.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.ceil((low + high) / 2);
|
||||||
|
if (context.measureText(`${text.slice(0, middle)}${ellipsis}`).width <= maximumWidth)
|
||||||
|
low = middle;
|
||||||
|
else high = middle - 1;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, low)}${ellipsis}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintChangeIcon(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
file: DiffFileSection,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
palette: DiffPalette,
|
||||||
|
): void {
|
||||||
|
const change = diffFileChangeKind(file.file);
|
||||||
|
context.save();
|
||||||
|
context.translate(x, y);
|
||||||
|
context.scale(DIFF_FILE_HEADER_ICON_SIZE / 24, DIFF_FILE_HEADER_ICON_SIZE / 24);
|
||||||
|
if (change === "added") context.strokeStyle = palette.statusSuccess;
|
||||||
|
else if (change === "deleted") context.strokeStyle = palette.statusDanger;
|
||||||
|
else context.strokeStyle = palette.statusWarning;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(3, 3, 18, 18, 2);
|
||||||
|
if (change === "added") {
|
||||||
|
context.moveTo(8, 12);
|
||||||
|
context.lineTo(16, 12);
|
||||||
|
context.moveTo(12, 8);
|
||||||
|
context.lineTo(12, 16);
|
||||||
|
} else if (change === "deleted") {
|
||||||
|
context.moveTo(8, 12);
|
||||||
|
context.lineTo(16, 12);
|
||||||
|
} else {
|
||||||
|
context.moveTo(12, 12);
|
||||||
|
context.lineTo(12.01, 12);
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function paintLine(
|
function paintLine(
|
||||||
input: PaintWebViewportInput & {
|
input: PaintWebViewportInput & {
|
||||||
row: DiffLineRow;
|
row: DiffLineRow;
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const palette: DiffPalette = {
|
|||||||
deletionBackground: "red-bg",
|
deletionBackground: "red-bg",
|
||||||
emptyBackground: "empty",
|
emptyBackground: "empty",
|
||||||
selection: "selection",
|
selection: "selection",
|
||||||
|
headerActiveSurface: "active-header",
|
||||||
|
headerBorder: "header-border",
|
||||||
|
statusSuccess: "success",
|
||||||
|
statusDanger: "danger",
|
||||||
|
statusWarning: "warning",
|
||||||
syntax: {},
|
syntax: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export function createDiffPalette(theme: Theme): DiffPalette {
|
|||||||
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
|
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
|
||||||
emptyBackground: theme.colors.surface0,
|
emptyBackground: theme.colors.surface0,
|
||||||
selection: theme.colors.terminal.blue,
|
selection: theme.colors.terminal.blue,
|
||||||
|
headerActiveSurface: theme.colors.surface1,
|
||||||
|
headerBorder: theme.colors.borderAccent,
|
||||||
|
statusSuccess: theme.colors.statusSuccess,
|
||||||
|
statusDanger: theme.colors.statusDanger,
|
||||||
|
statusWarning: theme.colors.statusWarning,
|
||||||
syntax: theme.colors.syntax,
|
syntax: theme.colors.syntax,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,6 +37,11 @@ export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): Dif
|
|||||||
previous.deletionBackground !== next.deletionBackground ||
|
previous.deletionBackground !== next.deletionBackground ||
|
||||||
previous.emptyBackground !== next.emptyBackground ||
|
previous.emptyBackground !== next.emptyBackground ||
|
||||||
previous.selection !== next.selection ||
|
previous.selection !== next.selection ||
|
||||||
|
previous.headerActiveSurface !== next.headerActiveSurface ||
|
||||||
|
previous.headerBorder !== next.headerBorder ||
|
||||||
|
previous.statusSuccess !== next.statusSuccess ||
|
||||||
|
previous.statusDanger !== next.statusDanger ||
|
||||||
|
previous.statusWarning !== next.statusWarning ||
|
||||||
!sameColorMap(previous.syntax, next.syntax)
|
!sameColorMap(previous.syntax, next.syntax)
|
||||||
) {
|
) {
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import { InlineReviewThread } from "@/review";
|
|||||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import { DocumentFileHeader } from "./document-file-header";
|
import { DocumentFileHeader } from "./document-file-header";
|
||||||
|
import {
|
||||||
|
diffInteractionWindowTop,
|
||||||
|
diffMaterializationWindow,
|
||||||
|
resolveVisibleFileSections,
|
||||||
|
} from "./header-layout";
|
||||||
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
||||||
import { retainDiffViewport } from "./viewport";
|
import { retainDiffViewport } from "./viewport";
|
||||||
import { HorizontalScroll } from "./horizontal-scroll.native";
|
import { HorizontalScroll } from "./horizontal-scroll.native";
|
||||||
@@ -42,16 +47,22 @@ import {
|
|||||||
nativeCanvasSlabsForViewport,
|
nativeCanvasSlabsForViewport,
|
||||||
nativeCanvasWindowBucket,
|
nativeCanvasWindowBucket,
|
||||||
nativeCanvasWindowTop,
|
nativeCanvasWindowTop,
|
||||||
nativeStickyHeaderIndices,
|
|
||||||
type NativeCanvasSlab,
|
type NativeCanvasSlab,
|
||||||
} from "./native-slabs";
|
} from "./native-slabs";
|
||||||
import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native";
|
import {
|
||||||
|
createNativePaints,
|
||||||
|
recordNativeHeaderPicture,
|
||||||
|
recordNativeSlabPictures,
|
||||||
|
type NativePaints,
|
||||||
|
} from "./paint.native";
|
||||||
import {
|
import {
|
||||||
createNativeTextLayoutStore,
|
createNativeTextLayoutStore,
|
||||||
|
createNativeHeaderTextLayout,
|
||||||
createNativeTextMeasurer,
|
createNativeTextMeasurer,
|
||||||
disposeNativeTextLayout,
|
disposeNativeTextLayout,
|
||||||
prepareNativeTextLayout,
|
prepareNativeTextLayout,
|
||||||
type NativeTextLayout,
|
type NativeTextLayout,
|
||||||
|
type NativeHeaderTextLayout,
|
||||||
} from "./text.native";
|
} from "./text.native";
|
||||||
import type {
|
import type {
|
||||||
DiffDocumentModel,
|
DiffDocumentModel,
|
||||||
@@ -68,6 +79,7 @@ const CODE_LEFT_PADDING = 8;
|
|||||||
export function DiffSurface(props: DiffSurfaceProps) {
|
export function DiffSurface(props: DiffSurfaceProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||||
|
const [fileWindowTop, setFileWindowTop] = useState(0);
|
||||||
const scrollRef = useRef<ScrollView>(null);
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||||
const reusableModelRef = useRef<{
|
const reusableModelRef = useRef<{
|
||||||
@@ -126,6 +138,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
binary: t("workspace.git.diff.binaryFile"),
|
binary: t("workspace.git.diff.binaryFile"),
|
||||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||||
},
|
},
|
||||||
|
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||||
reuseFrom,
|
reuseFrom,
|
||||||
});
|
});
|
||||||
reusableModelRef.current = {
|
reusableModelRef.current = {
|
||||||
@@ -135,6 +148,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
return next;
|
return next;
|
||||||
}, [
|
}, [
|
||||||
measurement,
|
measurement,
|
||||||
|
fileWindowTop,
|
||||||
props.collapsedFilePaths,
|
props.collapsedFilePaths,
|
||||||
props.displayPreferences.layout,
|
props.displayPreferences.layout,
|
||||||
props.displayPreferences.wrapLines,
|
props.displayPreferences.wrapLines,
|
||||||
@@ -144,6 +158,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
t,
|
t,
|
||||||
typography,
|
typography,
|
||||||
viewport.width,
|
viewport.width,
|
||||||
|
viewport.height,
|
||||||
]);
|
]);
|
||||||
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
|
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
|
||||||
const textLayoutStore = useMemo(
|
const textLayoutStore = useMemo(
|
||||||
@@ -161,6 +176,38 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
[model, textLayoutStore],
|
[model, textLayoutStore],
|
||||||
);
|
);
|
||||||
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
||||||
|
const headerTextLayout = useMemo(
|
||||||
|
() =>
|
||||||
|
createNativeHeaderTextLayout({
|
||||||
|
configuredFamily: props.headerTypography.family,
|
||||||
|
fontSize: props.headerTypography.size,
|
||||||
|
statFontSize: props.headerTypography.statSize,
|
||||||
|
palette: props.palette,
|
||||||
|
}),
|
||||||
|
[props.headerTypography, props.palette],
|
||||||
|
);
|
||||||
|
const updateFileWindow = useCallback((nextTop: number) => {
|
||||||
|
setFileWindowTop((current) => (current === nextTop ? current : nextTop));
|
||||||
|
}, []);
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => diffInteractionWindowTop(scrollTop.value, viewport.height),
|
||||||
|
(windowTop, previous) => {
|
||||||
|
if (windowTop !== previous) {
|
||||||
|
scheduleOnRN(updateFileWindow, windowTop);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateFileWindow, viewport.height],
|
||||||
|
);
|
||||||
|
const interactionFiles = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveVisibleFileSections({
|
||||||
|
files: model.files,
|
||||||
|
scrollTop: fileWindowTop,
|
||||||
|
viewportHeight: viewport.height,
|
||||||
|
overscan: viewport.height * 2,
|
||||||
|
}).files,
|
||||||
|
[fileWindowTop, model.files, viewport.height],
|
||||||
|
);
|
||||||
|
|
||||||
const scrollHandler = useAnimatedScrollHandler((event) => {
|
const scrollHandler = useAnimatedScrollHandler((event) => {
|
||||||
scrollTop.value = event.contentOffset.y;
|
scrollTop.value = event.contentOffset.y;
|
||||||
@@ -206,12 +253,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
}
|
}
|
||||||
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
|
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
|
||||||
const contentStyle = useMemo(
|
const contentStyle = useMemo(
|
||||||
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
|
() => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
|
||||||
[viewport.height],
|
[model.height, viewport.height],
|
||||||
);
|
|
||||||
const stickyHeaderIndices = useMemo(
|
|
||||||
() => nativeStickyHeaderIndices(model.files.length),
|
|
||||||
[model.files.length],
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
||||||
@@ -222,7 +265,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
stickyHeaderIndices={stickyHeaderIndices}
|
|
||||||
testID="git-diff-scroll"
|
testID="git-diff-scroll"
|
||||||
>
|
>
|
||||||
<NativeCanvasSlabLayer
|
<NativeCanvasSlabLayer
|
||||||
@@ -233,23 +275,31 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
paints={paints}
|
paints={paints}
|
||||||
horizontalOffsets={horizontalOffsets}
|
horizontalOffsets={horizontalOffsets}
|
||||||
/>
|
/>
|
||||||
{model.files.flatMap((file) => [
|
{interactionFiles.map((file) => (
|
||||||
<NativeStickyFileHeader
|
<NativeCanvasFileHeader
|
||||||
key={`${file.path}:header`}
|
key={`${file.path}:header`}
|
||||||
file={file}
|
file={file}
|
||||||
|
viewportWidth={model.viewportWidth}
|
||||||
|
scrollTop={scrollTop}
|
||||||
|
textLayout={headerTextLayout}
|
||||||
|
paints={paints}
|
||||||
|
headerSurface={props.palette.headerSurface}
|
||||||
|
headerActiveSurface={props.palette.headerActiveSurface}
|
||||||
selectedPath={props.selectedPath}
|
selectedPath={props.selectedPath}
|
||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
onToggleFile={props.onToggleFile}
|
onToggleFile={props.onToggleFile}
|
||||||
onSelectPath={props.onSelectPath}
|
onSelectPath={props.onSelectPath}
|
||||||
/>,
|
/>
|
||||||
|
))}
|
||||||
|
{interactionFiles.map((file) => (
|
||||||
<NativeFileBody
|
<NativeFileBody
|
||||||
key={`${file.path}:body`}
|
key={`${file.path}:body`}
|
||||||
file={file}
|
file={file}
|
||||||
model={model}
|
model={model}
|
||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
horizontalOffsets={horizontalOffsets}
|
horizontalOffsets={horizontalOffsets}
|
||||||
/>,
|
/>
|
||||||
])}
|
))}
|
||||||
<NativeReviewOverlays model={model} mode={props.mode} />
|
<NativeReviewOverlays model={model} mode={props.mode} />
|
||||||
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
|
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
|
||||||
</AnimatedScrollView>
|
</AnimatedScrollView>
|
||||||
@@ -306,29 +356,69 @@ function NativeCanvasSlabLayer({
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function NativeStickyFileHeader({
|
function NativeCanvasFileHeader({
|
||||||
file,
|
file,
|
||||||
|
viewportWidth,
|
||||||
|
scrollTop,
|
||||||
|
textLayout,
|
||||||
|
paints,
|
||||||
|
headerSurface,
|
||||||
|
headerActiveSurface,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
mode,
|
mode,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onSelectPath,
|
onSelectPath,
|
||||||
}: {
|
}: {
|
||||||
file: DiffFileSection;
|
file: DiffFileSection;
|
||||||
|
viewportWidth: number;
|
||||||
|
scrollTop: SharedValue<number>;
|
||||||
|
textLayout: NativeHeaderTextLayout;
|
||||||
|
paints: NativePaints;
|
||||||
|
headerSurface: string;
|
||||||
|
headerActiveSurface: string;
|
||||||
selectedPath: DiffSurfaceProps["selectedPath"];
|
selectedPath: DiffSurfaceProps["selectedPath"];
|
||||||
mode: DiffSurfaceProps["mode"];
|
mode: DiffSurfaceProps["mode"];
|
||||||
onToggleFile: DiffSurfaceProps["onToggleFile"];
|
onToggleFile: DiffSurfaceProps["onToggleFile"];
|
||||||
onSelectPath: DiffSurfaceProps["onSelectPath"];
|
onSelectPath: DiffSurfaceProps["onSelectPath"];
|
||||||
}) {
|
}) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const picture = useMemo(
|
||||||
|
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
|
||||||
|
[file, paints, textLayout, viewportWidth],
|
||||||
|
);
|
||||||
|
const stickyStyle = useAnimatedStyle(() => {
|
||||||
|
const pinOffset = Math.max(0, scrollTop.value - file.top);
|
||||||
|
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
|
||||||
|
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
|
||||||
|
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
|
||||||
|
const style = useMemo<ViewStyle>(
|
||||||
|
() => ({
|
||||||
|
position: "absolute",
|
||||||
|
top: file.top,
|
||||||
|
left: 0,
|
||||||
|
width: viewportWidth,
|
||||||
|
height: file.headerHeight,
|
||||||
|
backgroundColor: active ? headerActiveSurface : headerSurface,
|
||||||
|
zIndex: 5,
|
||||||
|
elevation: 2,
|
||||||
|
}),
|
||||||
|
[active, file.headerHeight, file.top, headerActiveSurface, headerSurface, viewportWidth],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<View style={styles.header}>
|
<Animated.View style={[style, stickyStyle]}>
|
||||||
|
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||||
|
<Picture picture={picture} />
|
||||||
|
</Canvas>
|
||||||
<DocumentFileHeader
|
<DocumentFileHeader
|
||||||
file={file}
|
file={file}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onToggleFile={onToggleFile}
|
onToggleFile={onToggleFile}
|
||||||
onSelectPath={onSelectPath}
|
onSelectPath={onSelectPath}
|
||||||
|
canvasRendered
|
||||||
|
onActiveChange={setActive}
|
||||||
/>
|
/>
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +472,10 @@ function NativeFileBody({
|
|||||||
<View
|
<View
|
||||||
testID={`diff-file-${file.fileIndex}-body`}
|
testID={`diff-file-${file.fileIndex}-body`}
|
||||||
style={inlineUnistylesStyle<ViewStyle>({
|
style={inlineUnistylesStyle<ViewStyle>({
|
||||||
position: "relative",
|
position: "absolute",
|
||||||
|
top: file.bodyTop,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
height: file.bodyHeight,
|
height: file.bodyHeight,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
})}
|
})}
|
||||||
@@ -579,5 +672,4 @@ function NativeSlabCode({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
||||||
scroll: { backgroundColor: "transparent" },
|
scroll: { backgroundColor: "transparent" },
|
||||||
header: { zIndex: 5 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overl
|
|||||||
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
|
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
|
||||||
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||||
import { DocumentFileHeader } from "./document-file-header";
|
import { DocumentFileHeader } from "./document-file-header";
|
||||||
|
import {
|
||||||
|
diffHeaderViewportKey,
|
||||||
|
diffInteractionWindowTop,
|
||||||
|
diffMaterializationWindow,
|
||||||
|
resolveVisibleFileSections,
|
||||||
|
} from "./header-layout";
|
||||||
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||||
import { paintWebViewport } from "./paint.web";
|
import { paintWebHeaders, paintWebViewport } from "./paint.web";
|
||||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||||
import { createMeasuredAdvances } from "./text-measurement";
|
import { createMeasuredAdvances } from "./text-measurement";
|
||||||
import { retainDiffViewport } from "./viewport";
|
import { retainDiffViewport } from "./viewport";
|
||||||
@@ -30,6 +36,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const workspaceCache = useDiffDocumentWorkspaceCache();
|
const workspaceCache = useDiffDocumentWorkspaceCache();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const headerCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||||
@@ -46,6 +53,17 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
dismissSelectionOnClick: boolean;
|
dismissSelectionOnClick: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const frameRef = useRef<number | null>(null);
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const headerFrameRef = useRef<number | null>(null);
|
||||||
|
const headerPaintRef = useRef<{
|
||||||
|
key: string;
|
||||||
|
model: ReturnType<typeof buildDiffDocumentModel>;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
ratio: number;
|
||||||
|
palette: DiffSurfaceProps["palette"];
|
||||||
|
typography: DiffSurfaceProps["headerTypography"];
|
||||||
|
} | null>(null);
|
||||||
|
const activeHeaderPathRef = useRef<string | null>(null);
|
||||||
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const resizeReleaseFrameRef = useRef<number | null>(null);
|
const resizeReleaseFrameRef = useRef<number | null>(null);
|
||||||
const resizePointerActiveRef = useRef(false);
|
const resizePointerActiveRef = useRef(false);
|
||||||
@@ -53,6 +71,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const forcePaintRef = useRef(true);
|
const forcePaintRef = useRef(true);
|
||||||
const canvasWindowRef = useRef({ top: 0, height: 0 });
|
const canvasWindowRef = useRef({ top: 0, height: 0 });
|
||||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||||
|
const [fileWindowTop, setFileWindowTop] = useState(0);
|
||||||
|
const [interactionFiles, setInteractionFiles] = useState<
|
||||||
|
ReturnType<typeof buildDiffDocumentModel>["files"]
|
||||||
|
>([]);
|
||||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||||
hit: Extract<DiffHit, { kind: "cell" }>;
|
hit: Extract<DiffHit, { kind: "cell" }>;
|
||||||
left: number;
|
left: number;
|
||||||
@@ -81,12 +103,18 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
|
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||||
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
|
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||||
|
document.fonts.load(
|
||||||
|
`400 ${props.headerTypography.size}px ${props.headerTypography.family}`,
|
||||||
|
),
|
||||||
|
document.fonts.load(
|
||||||
|
`400 ${props.headerTypography.statSize}px ${props.headerTypography.family}`,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
},
|
},
|
||||||
createMeasurer: () => createWebTextMeasurer(desiredTypography),
|
createMeasurer: () => createWebTextMeasurer(desiredTypography),
|
||||||
}),
|
}),
|
||||||
[desiredTypography, workspaceCache],
|
[desiredTypography, props.headerTypography, workspaceCache],
|
||||||
);
|
);
|
||||||
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
|
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
|
||||||
typeof workspaceCache.typography
|
typeof workspaceCache.typography
|
||||||
@@ -105,7 +133,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
lineHeight: desiredTypography.lineHeight,
|
lineHeight: desiredTypography.lineHeight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return workspaceCache.buildModel({
|
const next = workspaceCache.buildModel({
|
||||||
files: props.files,
|
files: props.files,
|
||||||
collapsedFilePaths: props.collapsedFilePaths,
|
collapsedFilePaths: props.collapsedFilePaths,
|
||||||
layout: props.displayPreferences.layout,
|
layout: props.displayPreferences.layout,
|
||||||
@@ -119,8 +147,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
binary: t("workspace.git.diff.binaryFile"),
|
binary: t("workspace.git.diff.binaryFile"),
|
||||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||||
},
|
},
|
||||||
|
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||||
});
|
});
|
||||||
|
return next;
|
||||||
}, [
|
}, [
|
||||||
|
fileWindowTop,
|
||||||
measurement,
|
measurement,
|
||||||
props.collapsedFilePaths,
|
props.collapsedFilePaths,
|
||||||
props.displayPreferences.layout,
|
props.displayPreferences.layout,
|
||||||
@@ -132,6 +163,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
desiredTypography.lineHeight,
|
desiredTypography.lineHeight,
|
||||||
loadedTypography,
|
loadedTypography,
|
||||||
viewport.width,
|
viewport.width,
|
||||||
|
viewport.height,
|
||||||
workspaceCache,
|
workspaceCache,
|
||||||
]);
|
]);
|
||||||
modelRef.current = model;
|
modelRef.current = model;
|
||||||
@@ -213,6 +245,64 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
},
|
},
|
||||||
[paint],
|
[paint],
|
||||||
);
|
);
|
||||||
|
const paintHeaders = useCallback(() => {
|
||||||
|
headerFrameRef.current = null;
|
||||||
|
const canvas = headerCanvasRef.current;
|
||||||
|
const currentModel = modelRef.current;
|
||||||
|
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
const key = diffHeaderViewportKey({
|
||||||
|
files: currentModel.files,
|
||||||
|
scrollTop: scrollTopRef.current,
|
||||||
|
viewportHeight: viewport.height,
|
||||||
|
activePath: activeHeaderPathRef.current,
|
||||||
|
});
|
||||||
|
const previous = headerPaintRef.current;
|
||||||
|
if (
|
||||||
|
previous?.key === key &&
|
||||||
|
previous.model === currentModel &&
|
||||||
|
previous.width === viewport.width &&
|
||||||
|
previous.height === viewport.height &&
|
||||||
|
previous.ratio === ratio &&
|
||||||
|
previous.palette === props.palette &&
|
||||||
|
previous.typography === props.headerTypography
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
headerPaintRef.current = {
|
||||||
|
key,
|
||||||
|
model: currentModel,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
ratio,
|
||||||
|
palette: props.palette,
|
||||||
|
typography: props.headerTypography,
|
||||||
|
};
|
||||||
|
const pixelWidth = Math.ceil(viewport.width * ratio);
|
||||||
|
const pixelHeight = Math.ceil(viewport.height * ratio);
|
||||||
|
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||||
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||||
|
canvas.style.width = `${viewport.width}px`;
|
||||||
|
canvas.style.height = `${viewport.height}px`;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
paintWebHeaders({
|
||||||
|
context,
|
||||||
|
model: currentModel,
|
||||||
|
palette: props.palette,
|
||||||
|
typography: props.headerTypography,
|
||||||
|
scrollTop: scrollTopRef.current,
|
||||||
|
viewportWidth: viewport.width,
|
||||||
|
viewportHeight: viewport.height,
|
||||||
|
devicePixelRatio: ratio,
|
||||||
|
activePath: activeHeaderPathRef.current,
|
||||||
|
});
|
||||||
|
}, [props.headerTypography, props.palette, viewport.height, viewport.width]);
|
||||||
|
const scheduleHeaderPaint = useCallback(() => {
|
||||||
|
if (headerFrameRef.current === null) {
|
||||||
|
headerFrameRef.current = requestAnimationFrame(paintHeaders);
|
||||||
|
}
|
||||||
|
}, [paintHeaders]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
@@ -300,13 +390,37 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
props.files.map((file) => file.path),
|
props.files.map((file) => file.path),
|
||||||
);
|
);
|
||||||
}, [props.files]);
|
}, [props.files]);
|
||||||
useLayoutEffect(schedulePaint, [model, schedulePaint]);
|
const updateInteractionFiles = useCallback(
|
||||||
|
(scrollTop: number) => {
|
||||||
|
const windowTop = diffInteractionWindowTop(scrollTop, viewport.height);
|
||||||
|
setFileWindowTop((current) => (current === windowTop ? current : windowTop));
|
||||||
|
const next = resolveVisibleFileSections({
|
||||||
|
files: model.files,
|
||||||
|
scrollTop: windowTop,
|
||||||
|
viewportHeight: viewport.height,
|
||||||
|
overscan: viewport.height * 2,
|
||||||
|
}).files;
|
||||||
|
setInteractionFiles((current) =>
|
||||||
|
current.length === next.length && current[0] === next[0] && current.at(-1) === next.at(-1)
|
||||||
|
? current
|
||||||
|
: next,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[model.files, viewport.height],
|
||||||
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
schedulePaint();
|
||||||
|
scheduleHeaderPaint();
|
||||||
|
updateInteractionFiles(scrollTopRef.current);
|
||||||
|
}, [model, scheduleHeaderPaint, schedulePaint, updateInteractionFiles]);
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||||
|
if (headerFrameRef.current !== null) cancelAnimationFrame(headerFrameRef.current);
|
||||||
// Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
|
// Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
|
||||||
// request so the refreshed renderer can schedule its first paint.
|
// request so the refreshed renderer can schedule its first paint.
|
||||||
frameRef.current = null;
|
frameRef.current = null;
|
||||||
|
headerFrameRef.current = null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -331,6 +445,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
(scrollElement: HTMLDivElement) => {
|
(scrollElement: HTMLDivElement) => {
|
||||||
const scrollTop = scrollElement.scrollTop;
|
const scrollTop = scrollElement.scrollTop;
|
||||||
scrollTopRef.current = scrollTop;
|
scrollTopRef.current = scrollTop;
|
||||||
|
if (activeHeaderPathRef.current !== null) activeHeaderPathRef.current = null;
|
||||||
|
scheduleHeaderPaint();
|
||||||
|
updateInteractionFiles(scrollTop);
|
||||||
if (hasHoveredAffordanceRef.current) {
|
if (hasHoveredAffordanceRef.current) {
|
||||||
hasHoveredAffordanceRef.current = false;
|
hasHoveredAffordanceRef.current = false;
|
||||||
setHoveredAffordance(null);
|
setHoveredAffordance(null);
|
||||||
@@ -355,7 +472,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const nextTop = Math.round(requestedTop * ratio) / ratio;
|
const nextTop = Math.round(requestedTop * ratio) / ratio;
|
||||||
if (nextTop !== currentWindow.top) schedulePaint(false);
|
if (nextTop !== currentWindow.top) schedulePaint(false);
|
||||||
},
|
},
|
||||||
[schedulePaint, viewport.height],
|
[scheduleHeaderPaint, schedulePaint, updateInteractionFiles, viewport.height],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scroll = scrollRef.current;
|
const scroll = scrollRef.current;
|
||||||
@@ -420,8 +537,20 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
},
|
},
|
||||||
[pointHit, schedulePaint],
|
[pointHit, schedulePaint],
|
||||||
);
|
);
|
||||||
|
const updateActiveHeader = useCallback(
|
||||||
|
(target: EventTarget | null) => {
|
||||||
|
const header =
|
||||||
|
target instanceof Element ? target.closest<HTMLElement>("[data-diff-header]") : null;
|
||||||
|
const activeHeaderPath = header?.dataset.diffHeaderPath ?? null;
|
||||||
|
if (activeHeaderPathRef.current === activeHeaderPath) return;
|
||||||
|
activeHeaderPathRef.current = activeHeaderPath;
|
||||||
|
scheduleHeaderPaint();
|
||||||
|
},
|
||||||
|
[scheduleHeaderPaint],
|
||||||
|
);
|
||||||
const pointerMove = useCallback(
|
const pointerMove = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
updateActiveHeader(event.target);
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (drag) {
|
if (drag) {
|
||||||
drag.moved = hasPointerDragStarted({
|
drag.moved = hasPointerDragStarted({
|
||||||
@@ -463,7 +592,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
|
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
|
||||||
schedulePaint();
|
schedulePaint();
|
||||||
},
|
},
|
||||||
[pointHit, schedulePaint, viewport.width],
|
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
|
||||||
);
|
);
|
||||||
const pointerUp = useCallback(
|
const pointerUp = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -505,6 +634,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const cancelPointer = useCallback(() => {
|
const cancelPointer = useCallback(() => {
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]);
|
||||||
const copy = useCallback(
|
const copy = useCallback(
|
||||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
if (!selectionRef.current) return;
|
if (!selectionRef.current) return;
|
||||||
@@ -554,12 +684,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
onPointerUp={pointerUp}
|
onPointerUp={pointerUp}
|
||||||
onPointerCancel={cancelPointer}
|
onPointerCancel={cancelPointer}
|
||||||
onLostPointerCapture={cancelPointer}
|
onLostPointerCapture={cancelPointer}
|
||||||
|
onPointerLeave={pointerLeave}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
style={SCROLL_STYLE}
|
style={SCROLL_STYLE}
|
||||||
>
|
>
|
||||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||||
{model.files.map((file) => (
|
{interactionFiles.map((file) => (
|
||||||
<WebFileHeaderSection key={file.path} file={file}>
|
<WebFileHeaderSection key={file.path} file={file}>
|
||||||
<DocumentFileHeader
|
<DocumentFileHeader
|
||||||
file={file}
|
file={file}
|
||||||
@@ -567,10 +698,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
onToggleFile={props.onToggleFile}
|
onToggleFile={props.onToggleFile}
|
||||||
onSelectPath={props.onSelectPath}
|
onSelectPath={props.onSelectPath}
|
||||||
|
canvasRendered
|
||||||
/>
|
/>
|
||||||
</WebFileHeaderSection>
|
</WebFileHeaderSection>
|
||||||
))}
|
))}
|
||||||
{model.files
|
{interactionFiles
|
||||||
.filter((file) => !file.isCollapsed && !model.wrapLines)
|
.filter((file) => !file.isCollapsed && !model.wrapLines)
|
||||||
.map((file) => (
|
.map((file) => (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
@@ -609,6 +741,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<canvas
|
||||||
|
ref={headerCanvasRef}
|
||||||
|
data-testid="git-diff-header-canvas"
|
||||||
|
style={HEADER_CANVAS_STYLE}
|
||||||
|
/>
|
||||||
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
||||||
{hoveredAffordance?.hit.target && reviewActions ? (
|
{hoveredAffordance?.hit.target && reviewActions ? (
|
||||||
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
||||||
@@ -686,6 +823,7 @@ function WebFileHeaderSection({
|
|||||||
<div style={style}>
|
<div style={style}>
|
||||||
<div
|
<div
|
||||||
data-diff-header="true"
|
data-diff-header="true"
|
||||||
|
data-diff-header-path={file.path}
|
||||||
style={STICKY_HEADER_STYLE}
|
style={STICKY_HEADER_STYLE}
|
||||||
onMouseDown={preventDocumentMouseSelection}
|
onMouseDown={preventDocumentMouseSelection}
|
||||||
>
|
>
|
||||||
@@ -766,6 +904,7 @@ const FILE_SECTION_STYLE: React.CSSProperties = {
|
|||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
|
userSelect: "none",
|
||||||
};
|
};
|
||||||
const STICKY_HEADER_STYLE: React.CSSProperties = {
|
const STICKY_HEADER_STYLE: React.CSSProperties = {
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
@@ -789,6 +928,12 @@ const CANVAS_STYLE: React.CSSProperties = {
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
};
|
};
|
||||||
|
const HEADER_CANVAS_STYLE: React.CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 3,
|
||||||
|
pointerEvents: "none",
|
||||||
|
};
|
||||||
const AFFORDANCE_STYLE: ViewStyle = {
|
const AFFORDANCE_STYLE: ViewStyle = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
@@ -808,6 +953,7 @@ function emptyDiffDocumentModel(input: {
|
|||||||
layout: input.layout,
|
layout: input.layout,
|
||||||
wrapLines: input.wrapLines,
|
wrapLines: input.wrapLines,
|
||||||
viewportWidth: input.viewportWidth,
|
viewportWidth: input.viewportWidth,
|
||||||
|
reviewGeometryKey: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,66 @@ export interface NativeTextLayout {
|
|||||||
paragraphs: Array<Array<Array<SkParagraph | null>>>;
|
paragraphs: Array<Array<Array<SkParagraph | null>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NativeHeaderTextLayout {
|
||||||
|
families: string[] | undefined;
|
||||||
|
fontSize: number;
|
||||||
|
statFontSize: number;
|
||||||
|
palette: Pick<DiffPalette, "foreground" | "foregroundMuted" | "statusSuccess" | "statusDanger">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeShapedHeaderText {
|
||||||
|
paragraph: SkParagraph;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NativeHeaderTextTone = keyof NativeHeaderTextLayout["palette"];
|
||||||
|
|
||||||
|
export function createNativeHeaderTextLayout(input: {
|
||||||
|
configuredFamily: string;
|
||||||
|
fontSize: number;
|
||||||
|
statFontSize: number;
|
||||||
|
palette: NativeHeaderTextLayout["palette"];
|
||||||
|
}): NativeHeaderTextLayout {
|
||||||
|
const configured = input.configuredFamily
|
||||||
|
.split(",")
|
||||||
|
.map((family) => family.trim().replace(/^['"]|['"]$/g, ""))
|
||||||
|
// These are React Native's platform-default UI sentinels, not custom faces.
|
||||||
|
// Omitting the family lets Skia resolve the same platform system font.
|
||||||
|
.filter((family) => family && family !== "normal" && family !== "system-ui");
|
||||||
|
const families = configured.length > 0 ? [...new Set(configured)] : undefined;
|
||||||
|
return {
|
||||||
|
families,
|
||||||
|
fontSize: input.fontSize,
|
||||||
|
statFontSize: input.statFontSize,
|
||||||
|
palette: input.palette,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapeNativeHeaderText(input: {
|
||||||
|
layout: NativeHeaderTextLayout;
|
||||||
|
text: string;
|
||||||
|
size: "body" | "stat";
|
||||||
|
tone: NativeHeaderTextTone;
|
||||||
|
maximumWidth?: number;
|
||||||
|
}): NativeShapedHeaderText {
|
||||||
|
const paragraph = Skia.ParagraphBuilder.Make({ maxLines: 1, ellipsis: "…" })
|
||||||
|
.pushStyle({
|
||||||
|
...(input.layout.families ? { fontFamilies: input.layout.families } : {}),
|
||||||
|
fontSize: input.size === "body" ? input.layout.fontSize : input.layout.statFontSize,
|
||||||
|
color: Skia.Color(input.layout.palette[input.tone]),
|
||||||
|
})
|
||||||
|
.addText(input.text)
|
||||||
|
.pop()
|
||||||
|
.build();
|
||||||
|
paragraph.layout(input.maximumWidth ?? PARAGRAPH_WIDTH);
|
||||||
|
return {
|
||||||
|
paragraph,
|
||||||
|
width: paragraph.getLongestLine(),
|
||||||
|
height: paragraph.getHeight(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface NativeTextLayoutStore {
|
export interface NativeTextLayoutStore {
|
||||||
font: SkFont;
|
font: SkFont;
|
||||||
asciiMetrics: CachedAsciiTextMetrics;
|
asciiMetrics: CachedAsciiTextMetrics;
|
||||||
|
|||||||
@@ -61,9 +61,20 @@ export interface DiffPalette {
|
|||||||
deletionBackground: string;
|
deletionBackground: string;
|
||||||
emptyBackground: string;
|
emptyBackground: string;
|
||||||
selection: string;
|
selection: string;
|
||||||
|
headerActiveSurface: string;
|
||||||
|
headerBorder: string;
|
||||||
|
statusSuccess: string;
|
||||||
|
statusDanger: string;
|
||||||
|
statusWarning: string;
|
||||||
syntax: Record<string, string>;
|
syntax: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiffHeaderTypography {
|
||||||
|
family: string;
|
||||||
|
size: number;
|
||||||
|
statSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiffTokenRun {
|
export interface DiffTokenRun {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
@@ -150,6 +161,7 @@ export interface DiffDocumentModel {
|
|||||||
layout: "unified" | "split";
|
layout: "unified" | "split";
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
|
reviewGeometryKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextMeasurer {
|
export interface TextMeasurer {
|
||||||
@@ -168,6 +180,7 @@ export interface BuildDiffDocumentModelInput {
|
|||||||
palette: DiffPalette;
|
palette: DiffPalette;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
labels: { binary: string; tooLarge: string };
|
labels: { binary: string; tooLarge: string };
|
||||||
|
materializationWindow?: { top: number; height: number };
|
||||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||||
reuseFrom?: readonly DiffDocumentModel[];
|
reuseFrom?: readonly DiffDocumentModel[];
|
||||||
}
|
}
|
||||||
@@ -206,6 +219,7 @@ export type DiffScrollAnchor =
|
|||||||
|
|
||||||
export type DiffSurfaceProps = DiffDocumentProps & {
|
export type DiffSurfaceProps = DiffDocumentProps & {
|
||||||
palette: DiffPalette;
|
palette: DiffPalette;
|
||||||
|
headerTypography: DiffHeaderTypography;
|
||||||
collapsedFilePaths: ReadonlySet<string>;
|
collapsedFilePaths: ReadonlySet<string>;
|
||||||
onToggleFile: (path: string) => void;
|
onToggleFile: (path: string) => void;
|
||||||
selectedPath: string | null;
|
selectedPath: string | null;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
|
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
|
||||||
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
|
import type {
|
||||||
|
BuildDiffDocumentModelInput,
|
||||||
|
DiffDocumentModel,
|
||||||
|
DiffPalette,
|
||||||
|
TextMeasurer,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const palette: DiffPalette = {
|
const palette: DiffPalette = {
|
||||||
surface: "#000",
|
surface: "#000",
|
||||||
@@ -15,6 +20,11 @@ const palette: DiffPalette = {
|
|||||||
deletionBackground: "#100",
|
deletionBackground: "#100",
|
||||||
emptyBackground: "#111",
|
emptyBackground: "#111",
|
||||||
selection: "blue",
|
selection: "blue",
|
||||||
|
headerActiveSurface: "#222",
|
||||||
|
headerBorder: "#333",
|
||||||
|
statusSuccess: "green",
|
||||||
|
statusDanger: "red",
|
||||||
|
statusWarning: "orange",
|
||||||
syntax: { keyword: "purple" },
|
syntax: { keyword: "purple" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,6 +147,33 @@ describe("diff document workspace cache", () => {
|
|||||||
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
|
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("materializes successive unwrapped windows without remeasuring retained files", () => {
|
||||||
|
const cache = createDiffDocumentWorkspaceCache();
|
||||||
|
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
|
||||||
|
const { measureText, stats } = countingMeasurer();
|
||||||
|
const first = cache.buildModel(
|
||||||
|
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
|
||||||
|
);
|
||||||
|
const firstCalls = stats.calls;
|
||||||
|
const secondFile = first.files[1]!;
|
||||||
|
|
||||||
|
const second = cache.buildModel(
|
||||||
|
modelInput(files, measureText, {
|
||||||
|
materializationWindow: {
|
||||||
|
top: secondFile.top,
|
||||||
|
height: secondFile.bottom - secondFile.top,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstCalls).toBeGreaterThan(0);
|
||||||
|
expect(stats.calls).toBeGreaterThan(firstCalls);
|
||||||
|
expect(measuredFragmentCount(first, 0)).toBeGreaterThan(0);
|
||||||
|
expect(measuredFragmentCount(first, 1)).toBe(0);
|
||||||
|
expect(measuredFragmentCount(second, 0)).toBeGreaterThan(0);
|
||||||
|
expect(measuredFragmentCount(second, 1)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("shares loaded typography and its text measurer across mounts", async () => {
|
it("shares loaded typography and its text measurer across mounts", async () => {
|
||||||
const cache = createDiffDocumentWorkspaceCache();
|
const cache = createDiffDocumentWorkspaceCache();
|
||||||
const { measureText } = countingMeasurer();
|
const { measureText } = countingMeasurer();
|
||||||
@@ -171,3 +208,13 @@ describe("diff document workspace cache", () => {
|
|||||||
expect(resource.isReady()).toBe(true);
|
expect(resource.isReady()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function measuredFragmentCount(model: DiffDocumentModel, fileIndex: number): number {
|
||||||
|
const file = model.files[fileIndex];
|
||||||
|
if (!file) throw new Error(`Expected file ${fileIndex}`);
|
||||||
|
return model.rows
|
||||||
|
.slice(file.rowStart, file.rowEnd)
|
||||||
|
.filter((row) => row.kind === "line")
|
||||||
|
.flatMap((row) => row.cells)
|
||||||
|
.reduce((count, cell) => count + (cell?.fragments.length ?? 0), 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { buildDiffDocumentModel, retainReusableModels } from "./model";
|
import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model";
|
||||||
import type {
|
import type {
|
||||||
BuildDiffDocumentModelInput,
|
BuildDiffDocumentModelInput,
|
||||||
DiffDocumentModel,
|
DiffDocumentModel,
|
||||||
@@ -117,17 +117,11 @@ function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">):
|
|||||||
|
|
||||||
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
|
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
|
||||||
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
|
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
|
||||||
const reviewGeometry = input.reviewActions
|
const reviewGeometry = reviewGeometryKey(input.reviewActions);
|
||||||
? {
|
const materializationWindow = input.materializationWindow
|
||||||
comments: [...input.reviewActions.commentsByTarget.entries()]
|
? [input.materializationWindow.top, input.materializationWindow.height]
|
||||||
.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;
|
: null;
|
||||||
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
|
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function typographyKey(typography: DiffTypography): string {
|
function typographyKey(typography: DiffTypography): string {
|
||||||
@@ -150,6 +144,11 @@ function paletteKey(palette: DiffPalette): string {
|
|||||||
palette.deletionBackground,
|
palette.deletionBackground,
|
||||||
palette.emptyBackground,
|
palette.emptyBackground,
|
||||||
palette.selection,
|
palette.selection,
|
||||||
|
palette.headerActiveSurface,
|
||||||
|
palette.headerBorder,
|
||||||
|
palette.statusSuccess,
|
||||||
|
palette.statusDanger,
|
||||||
|
palette.statusWarning,
|
||||||
syntax,
|
syntax,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
reduceFileHeaderPress,
|
reduceFileHeaderPress,
|
||||||
shouldActivateStickyHeaderPress,
|
|
||||||
type FileHeaderPressEvent,
|
type FileHeaderPressEvent,
|
||||||
type FileHeaderPressPhase,
|
type FileHeaderPressPhase,
|
||||||
} from "./file-header-interaction";
|
} from "./file-header-interaction";
|
||||||
|
|
||||||
const pressOrigin = { startedAt: 1_000, pageX: 20, pageY: 30 };
|
|
||||||
|
|
||||||
describe("file header interaction arbitration", () => {
|
describe("file header interaction arbitration", () => {
|
||||||
it("activates an unhandled native sticky-header tap on press out", () => {
|
|
||||||
expect(
|
|
||||||
shouldActivateStickyHeaderPress({
|
|
||||||
enabled: true,
|
|
||||||
native: true,
|
|
||||||
pressHandled: false,
|
|
||||||
stickyPressFallback: true,
|
|
||||||
pressOrigin,
|
|
||||||
releasedAt: 1_500,
|
|
||||||
pageX: 32,
|
|
||||||
pageY: 30,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["an already handled long press", { pressHandled: true }],
|
|
||||||
["a non-native release", { native: false }],
|
|
||||||
["a non-sticky header", { stickyPressFallback: false }],
|
|
||||||
["a held press", { releasedAt: 1_501 }],
|
|
||||||
["a moved press", { pageX: 33 }],
|
|
||||||
])("does not activate %s", (_label, override) => {
|
|
||||||
expect(
|
|
||||||
shouldActivateStickyHeaderPress({
|
|
||||||
enabled: true,
|
|
||||||
native: true,
|
|
||||||
pressHandled: false,
|
|
||||||
stickyPressFallback: true,
|
|
||||||
pressOrigin,
|
|
||||||
releasedAt: 1_500,
|
|
||||||
pageX: 32,
|
|
||||||
pageY: 30,
|
|
||||||
...override,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
function run(events: FileHeaderPressEvent[]) {
|
function run(events: FileHeaderPressEvent[]) {
|
||||||
let phase: FileHeaderPressPhase = "idle";
|
let phase: FileHeaderPressPhase = "idle";
|
||||||
const effects: string[] = [];
|
const effects: string[] = [];
|
||||||
@@ -57,64 +17,17 @@ describe("file header interaction arbitration", () => {
|
|||||||
return effects;
|
return effects;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("lets a normal press claim a deferred sticky release exactly once", () => {
|
it("activates an actual press", () => {
|
||||||
expect(
|
expect(run([{ type: "press-in" }, { type: "press" }])).toEqual(["activate"]);
|
||||||
run([
|
|
||||||
{ type: "press-in" },
|
|
||||||
{ type: "press-out", stickyFallbackEligible: true },
|
|
||||||
{ type: "press" },
|
|
||||||
{ type: "fallback" },
|
|
||||||
]),
|
|
||||||
).toEqual(["defer-activate", "activate"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a duplicated wrapper press-out eligible for one fallback", () => {
|
it("does not activate when scrolling cancels the press", () => {
|
||||||
expect(
|
expect(run([{ type: "press-in" }])).toEqual([]);
|
||||||
run([
|
|
||||||
{ type: "press-in" },
|
|
||||||
{ type: "press-out", stickyFallbackEligible: true },
|
|
||||||
{ type: "press-out", stickyFallbackEligible: true },
|
|
||||||
{ type: "fallback" },
|
|
||||||
]),
|
|
||||||
).toEqual(["defer-activate", "activate"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back once for a canceled sticky press", () => {
|
it("selects a long press without activating when Pressability later emits press", () => {
|
||||||
expect(
|
expect(run([{ type: "press-in" }, { type: "long-press" }, { type: "press" }])).toEqual([
|
||||||
run([
|
"select",
|
||||||
{ type: "press-in" },
|
]);
|
||||||
{ type: "press-out", stickyFallbackEligible: true },
|
|
||||||
{ type: "fallback" },
|
|
||||||
]),
|
|
||||||
).toEqual(["defer-activate", "activate"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
"canceled non-sticky",
|
|
||||||
[{ type: "press-in" }, { type: "press-out", stickyFallbackEligible: false }],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"moved",
|
|
||||||
[
|
|
||||||
{ type: "press-in" },
|
|
||||||
{ type: "press-out", stickyFallbackEligible: false },
|
|
||||||
{ type: "fallback" },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
] as const)("does not activate a %s sequence", (_label, events) => {
|
|
||||||
expect(run([...events])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects a long press without activating when Pressability later emits press-out/press", () => {
|
|
||||||
expect(
|
|
||||||
run([
|
|
||||||
{ type: "press-in" },
|
|
||||||
{ type: "long-press" },
|
|
||||||
{ type: "press-out", stickyFallbackEligible: false },
|
|
||||||
{ type: "press" },
|
|
||||||
{ type: "fallback" },
|
|
||||||
]),
|
|
||||||
).toEqual(["select"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import type { LayoutChangeEvent } from "react-native";
|
import type { LayoutChangeEvent } from "react-native";
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
const STICKY_PRESS_MAX_DURATION_MS = 500;
|
|
||||||
const STICKY_PRESS_MAX_DISTANCE = 12;
|
|
||||||
|
|
||||||
interface FileHeaderInteractionInput {
|
interface FileHeaderInteractionInput {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -11,47 +7,22 @@ interface FileHeaderInteractionInput {
|
|||||||
onSelect?: (path: string) => void;
|
onSelect?: (path: string) => void;
|
||||||
onActivate?: (path: string) => void;
|
onActivate?: (path: string) => void;
|
||||||
onLayout?: (height: number) => void;
|
onLayout?: (height: number) => void;
|
||||||
stickyPressFallback?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PressPointEvent {
|
|
||||||
nativeEvent: { pageX: number; pageY: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PressOrigin {
|
|
||||||
startedAt: number;
|
|
||||||
pageX: number;
|
|
||||||
pageY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StickyPressReleaseInput {
|
|
||||||
enabled: boolean;
|
|
||||||
native: boolean;
|
|
||||||
pressHandled: boolean;
|
|
||||||
stickyPressFallback: boolean;
|
|
||||||
pressOrigin: PressOrigin | null;
|
|
||||||
releasedAt: number;
|
|
||||||
pageX: number;
|
|
||||||
pageY: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileHeaderInteraction {
|
export interface FileHeaderInteraction {
|
||||||
activate: () => void;
|
activate: () => void;
|
||||||
select: () => void;
|
select: () => void;
|
||||||
onLayout: (event: LayoutChangeEvent) => void;
|
onLayout: (event: LayoutChangeEvent) => void;
|
||||||
onPressIn: (event: PressPointEvent) => void;
|
onPressIn: () => void;
|
||||||
onPressOut: (event: PressPointEvent) => void;
|
|
||||||
onLongPress: () => void;
|
onLongPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileHeaderPressPhase = "idle" | "pressed" | "released" | "handled" | "long";
|
export type FileHeaderPressPhase = "idle" | "pressed" | "long" | "handled";
|
||||||
export type FileHeaderPressEvent =
|
export type FileHeaderPressEvent =
|
||||||
| { type: "press-in" }
|
| { type: "press-in" }
|
||||||
| { type: "press-out"; stickyFallbackEligible: boolean }
|
|
||||||
| { type: "press" }
|
| { type: "press" }
|
||||||
| { type: "long-press" }
|
| { type: "long-press" };
|
||||||
| { type: "fallback" };
|
export type FileHeaderPressEffect = "none" | "select" | "activate";
|
||||||
export type FileHeaderPressEffect = "none" | "select" | "activate" | "defer-activate";
|
|
||||||
|
|
||||||
export function reduceFileHeaderPress(
|
export function reduceFileHeaderPress(
|
||||||
phase: FileHeaderPressPhase,
|
phase: FileHeaderPressPhase,
|
||||||
@@ -59,62 +30,14 @@ export function reduceFileHeaderPress(
|
|||||||
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
|
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
|
||||||
if (event.type === "press-in") return { phase: "pressed", effect: "none" };
|
if (event.type === "press-in") return { phase: "pressed", effect: "none" };
|
||||||
if (event.type === "long-press") return { phase: "long", effect: "select" };
|
if (event.type === "long-press") return { phase: "long", effect: "select" };
|
||||||
if (event.type === "press-out") {
|
return phase === "long" || phase === "handled"
|
||||||
if (phase === "released" || phase === "handled" || phase === "long") {
|
? { phase: "handled", effect: "none" }
|
||||||
return { phase, effect: "none" };
|
: { phase: "handled", effect: "activate" };
|
||||||
}
|
|
||||||
return event.stickyFallbackEligible && phase === "pressed"
|
|
||||||
? { phase: "released", effect: "defer-activate" }
|
|
||||||
: { phase: "idle", effect: "none" };
|
|
||||||
}
|
|
||||||
if (event.type === "press") {
|
|
||||||
return phase === "long" || phase === "handled"
|
|
||||||
? { phase: "handled", effect: "none" }
|
|
||||||
: { phase: "handled", effect: "activate" };
|
|
||||||
}
|
|
||||||
return phase === "released"
|
|
||||||
? { phase: "handled", effect: "activate" }
|
|
||||||
: { phase, effect: "none" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput): boolean {
|
|
||||||
if (
|
|
||||||
!input.enabled ||
|
|
||||||
!input.native ||
|
|
||||||
input.pressHandled ||
|
|
||||||
!input.stickyPressFallback ||
|
|
||||||
!input.pressOrigin
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const durationMs = input.releasedAt - input.pressOrigin.startedAt;
|
|
||||||
const dx = input.pageX - input.pressOrigin.pageX;
|
|
||||||
const dy = input.pageY - input.pressOrigin.pageY;
|
|
||||||
return (
|
|
||||||
durationMs <= STICKY_PRESS_MAX_DURATION_MS && Math.hypot(dx, dy) <= STICKY_PRESS_MAX_DISTANCE
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
|
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
|
||||||
const {
|
const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input;
|
||||||
path,
|
|
||||||
enabled,
|
|
||||||
onSelect,
|
|
||||||
onActivate,
|
|
||||||
onLayout: notifyLayout,
|
|
||||||
stickyPressFallback = false,
|
|
||||||
} = input;
|
|
||||||
const pressHandledRef = useRef(false);
|
|
||||||
const pressOriginRef = useRef<PressOrigin | null>(null);
|
|
||||||
const phaseRef = useRef<FileHeaderPressPhase>("idle");
|
const phaseRef = useRef<FileHeaderPressPhase>("idle");
|
||||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const clearFallback = useCallback(() => {
|
|
||||||
if (fallbackTimerRef.current !== null) clearTimeout(fallbackTimerRef.current);
|
|
||||||
fallbackTimerRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => clearFallback, [clearFallback]);
|
|
||||||
|
|
||||||
const select = useCallback(() => {
|
const select = useCallback(() => {
|
||||||
if (enabled) onSelect?.(path);
|
if (enabled) onSelect?.(path);
|
||||||
@@ -122,14 +45,12 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
|||||||
|
|
||||||
const activate = useCallback(() => {
|
const activate = useCallback(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
clearFallback();
|
|
||||||
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
|
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
|
||||||
phaseRef.current = transition.phase;
|
phaseRef.current = transition.phase;
|
||||||
if (transition.effect !== "activate") return;
|
if (transition.effect !== "activate") return;
|
||||||
pressHandledRef.current = true;
|
|
||||||
select();
|
select();
|
||||||
onActivate?.(path);
|
onActivate?.(path);
|
||||||
}, [clearFallback, enabled, onActivate, path, select]);
|
}, [enabled, onActivate, path, select]);
|
||||||
|
|
||||||
const onLayout = useCallback(
|
const onLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
@@ -138,61 +59,15 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
|||||||
[notifyLayout],
|
[notifyLayout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPressIn = useCallback(
|
const onPressIn = useCallback(() => {
|
||||||
(event: PressPointEvent) => {
|
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
|
||||||
clearFallback();
|
}, []);
|
||||||
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
|
|
||||||
pressHandledRef.current = false;
|
|
||||||
pressOriginRef.current = {
|
|
||||||
startedAt: Date.now(),
|
|
||||||
pageX: event.nativeEvent.pageX,
|
|
||||||
pageY: event.nativeEvent.pageY,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[clearFallback],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onLongPress = useCallback(() => {
|
const onLongPress = useCallback(() => {
|
||||||
clearFallback();
|
|
||||||
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
|
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
|
||||||
phaseRef.current = transition.phase;
|
phaseRef.current = transition.phase;
|
||||||
pressHandledRef.current = true;
|
|
||||||
if (transition.effect === "select") select();
|
if (transition.effect === "select") select();
|
||||||
}, [clearFallback, select]);
|
}, [select]);
|
||||||
|
|
||||||
const onPressOut = useCallback(
|
return { activate, select, onLayout, onPressIn, onLongPress };
|
||||||
(event: PressPointEvent) => {
|
|
||||||
const eligible = shouldActivateStickyHeaderPress({
|
|
||||||
enabled,
|
|
||||||
native: isNative,
|
|
||||||
pressHandled: pressHandledRef.current,
|
|
||||||
stickyPressFallback,
|
|
||||||
pressOrigin: pressOriginRef.current,
|
|
||||||
releasedAt: Date.now(),
|
|
||||||
pageX: event.nativeEvent.pageX,
|
|
||||||
pageY: event.nativeEvent.pageY,
|
|
||||||
});
|
|
||||||
const transition = reduceFileHeaderPress(phaseRef.current, {
|
|
||||||
type: "press-out",
|
|
||||||
stickyFallbackEligible: eligible,
|
|
||||||
});
|
|
||||||
phaseRef.current = transition.phase;
|
|
||||||
if (transition.effect === "defer-activate") {
|
|
||||||
clearFallback();
|
|
||||||
fallbackTimerRef.current = setTimeout(() => {
|
|
||||||
fallbackTimerRef.current = null;
|
|
||||||
const fallback = reduceFileHeaderPress(phaseRef.current, { type: "fallback" });
|
|
||||||
phaseRef.current = fallback.phase;
|
|
||||||
if (fallback.effect === "activate") {
|
|
||||||
pressHandledRef.current = true;
|
|
||||||
select();
|
|
||||||
onActivate?.(path);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[clearFallback, enabled, onActivate, path, select, stickyPressFallback],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { activate, select, onLayout, onPressIn, onPressOut, onLongPress };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { allocateDiffHeaderTextWidths } from "./file-header-presentation";
|
||||||
|
|
||||||
|
describe("diff file header text allocation", () => {
|
||||||
|
it("gives the filename its full width before truncating the directory", () => {
|
||||||
|
expect(
|
||||||
|
allocateDiffHeaderTextWidths({ available: 120, nameWidth: 80, directoryWidth: 100 }),
|
||||||
|
).toEqual({ name: 80, directory: 36 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses all available width for a filename that cannot fit", () => {
|
||||||
|
expect(
|
||||||
|
allocateDiffHeaderTextWidths({ available: 60, nameWidth: 80, directoryWidth: 100 }),
|
||||||
|
).toEqual({ name: 60, directory: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not truncate a fitting filename merely to reserve the gap", () => {
|
||||||
|
expect(
|
||||||
|
allocateDiffHeaderTextWidths({ available: 82, nameWidth: 80, directoryWidth: 100 }),
|
||||||
|
).toEqual({ name: 80, directory: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||||
|
notation: "compact",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DIFF_FILE_HEADER_HEIGHT = 30;
|
||||||
|
export const DIFF_FILE_HEADER_CONTENT_HEIGHT = 28;
|
||||||
|
export const DIFF_FILE_HEADER_LEFT = 12;
|
||||||
|
export const DIFF_FILE_HEADER_RIGHT = 8;
|
||||||
|
export const DIFF_FILE_HEADER_ICON_SIZE = 14;
|
||||||
|
export const DIFF_FILE_HEADER_TEXT_GAP = 4;
|
||||||
|
|
||||||
|
export function allocateDiffHeaderTextWidths(input: {
|
||||||
|
available: number;
|
||||||
|
nameWidth: number;
|
||||||
|
directoryWidth: number;
|
||||||
|
}): { name: number; directory: number } {
|
||||||
|
const available = Math.max(0, input.available);
|
||||||
|
const nameWidth = Math.max(0, input.nameWidth);
|
||||||
|
const directoryWidth = Math.max(0, input.directoryWidth);
|
||||||
|
if (nameWidth >= available || directoryWidth === 0) {
|
||||||
|
return { name: Math.min(nameWidth, available), directory: 0 };
|
||||||
|
}
|
||||||
|
const directoryAvailable = Math.max(0, available - nameWidth - DIFF_FILE_HEADER_TEXT_GAP);
|
||||||
|
return { name: nameWidth, directory: Math.min(directoryWidth, directoryAvailable) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDiffCount(value: number): string {
|
||||||
|
return compactFormatter.format(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileNameForPath(path: string): string {
|
||||||
|
return path.split("/").pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directorySuffix(path: string): string {
|
||||||
|
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffFileChangeKind(file: {
|
||||||
|
isNew: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
}): "added" | "deleted" | "modified" {
|
||||||
|
if (file.isNew) return "added";
|
||||||
|
if (file.isDeleted) return "deleted";
|
||||||
|
return "modified";
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
|
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
|
||||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
import {
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type GestureResponderEvent,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
@@ -17,6 +22,12 @@ import {
|
|||||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useFileHeaderInteraction } from "@/git/file-header-interaction";
|
import { useFileHeaderInteraction } from "@/git/file-header-interaction";
|
||||||
|
import {
|
||||||
|
diffFileChangeKind,
|
||||||
|
directorySuffix,
|
||||||
|
fileNameForPath,
|
||||||
|
formatDiffCount,
|
||||||
|
} from "@/git/file-header-presentation";
|
||||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
|
||||||
@@ -43,14 +54,8 @@ export interface FileHeaderProps {
|
|||||||
onRevert?: (path: string, oldPath?: string) => void;
|
onRevert?: (path: string, oldPath?: string) => void;
|
||||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
canvasRendered?: boolean;
|
||||||
|
onActiveChange?: (active: boolean) => void;
|
||||||
function fileNameForPath(path: string): string {
|
|
||||||
return path.split("/").pop() ?? path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function directorySuffix(path: string): string {
|
|
||||||
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileHeaderAccessibilityState(input: {
|
function fileHeaderAccessibilityState(input: {
|
||||||
@@ -68,12 +73,22 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
|||||||
return showsBodyState ? bodyVisible : undefined;
|
return showsBodyState ? bodyVisible : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useFileHeaderHover(enabled: boolean) {
|
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
|
||||||
|
if (!canvasRendered) return undefined;
|
||||||
|
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFileHeaderHover(enabled: boolean, onActiveChange?: (active: boolean) => void) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const handlePointerEnter = useCallback(() => {
|
const handlePointerEnter = useCallback(() => {
|
||||||
if (enabled) setIsHovered(true);
|
if (!enabled) return;
|
||||||
}, [enabled]);
|
setIsHovered(true);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
onActiveChange?.(true);
|
||||||
|
}, [enabled, onActiveChange]);
|
||||||
|
const handlePointerLeave = useCallback(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
onActiveChange?.(false);
|
||||||
|
}, [onActiveChange]);
|
||||||
return { isHovered, handlePointerEnter, handlePointerLeave };
|
return { isHovered, handlePointerEnter, handlePointerLeave };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +111,8 @@ function fileHeaderInteractionStyle(input: {
|
|||||||
return input.showsBodyState ? styles.documentActive : workspaceTreeRowStyles.active;
|
return input.showsBodyState ? styles.documentActive : workspaceTreeRowStyles.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileHeaderPressFeedbackStyle(showsBodyState: boolean) {
|
function fileHeaderPressFeedbackStyle(showsBodyState: boolean, canvasRendered: boolean) {
|
||||||
|
if (canvasRendered) return undefined;
|
||||||
return showsBodyState ? styles.documentPressFeedback : workspaceTreeRowStyles.active;
|
return showsBodyState ? styles.documentPressFeedback : workspaceTreeRowStyles.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +131,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||||
if (file.isNew) return "added";
|
return diffFileChangeKind(file);
|
||||||
if (file.isDeleted) return "deleted";
|
|
||||||
return "modified";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileHeaderMenu({
|
function FileHeaderMenu({
|
||||||
@@ -181,9 +195,11 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onHeaderHeightChange,
|
onHeaderHeightChange,
|
||||||
testID,
|
testID,
|
||||||
|
canvasRendered = false,
|
||||||
|
onActiveChange,
|
||||||
...actions
|
...actions
|
||||||
}: FileHeaderProps) {
|
}: FileHeaderProps) {
|
||||||
const hover = useFileHeaderHover(interactive);
|
const hover = useFileHeaderHover(interactive, onActiveChange);
|
||||||
const dragSourceRef = useWorkspaceFileDragSource({
|
const dragSourceRef = useWorkspaceFileDragSource({
|
||||||
enabled: interactive,
|
enabled: interactive,
|
||||||
disabled: file.isDeleted,
|
disabled: file.isDeleted,
|
||||||
@@ -196,7 +212,6 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
enabled: interactive,
|
enabled: interactive,
|
||||||
onSelect,
|
onSelect,
|
||||||
onActivate,
|
onActivate,
|
||||||
stickyPressFallback: showsBodyState,
|
|
||||||
onLayout: useCallback(
|
onLayout: useCallback(
|
||||||
(height: number) => onHeaderHeightChange?.(file.path, height),
|
(height: number) => onHeaderHeightChange?.(file.path, height),
|
||||||
[file.path, onHeaderHeightChange],
|
[file.path, onHeaderHeightChange],
|
||||||
@@ -221,6 +236,13 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
],
|
],
|
||||||
[depth, hover.isHovered, isSelected, showsBodyState],
|
[depth, hover.isHovered, isSelected, showsBodyState],
|
||||||
);
|
);
|
||||||
|
const canvasPressableStyle = useCallback(
|
||||||
|
(state: PressableStateCallbackType) => [
|
||||||
|
pressableStyle(state),
|
||||||
|
canvasRendered && styles.canvasInteraction,
|
||||||
|
],
|
||||||
|
[canvasRendered, pressableStyle],
|
||||||
|
);
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
||||||
[bodyVisible, isSelected, showsBodyState],
|
[bodyVisible, isSelected, showsBodyState],
|
||||||
@@ -260,36 +282,67 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
const renderedContent = canvasRendered ? (
|
||||||
|
<View ref={dragSourceRef} style={styles.canvasInteractionContent} />
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
);
|
||||||
|
const accessibilityLabel = fileHeaderAccessibilityLabel(canvasRendered, file);
|
||||||
|
const handlePressIn = useCallback(
|
||||||
|
(_event: GestureResponderEvent) => {
|
||||||
|
onActiveChange?.(true);
|
||||||
|
interaction.onPressIn();
|
||||||
|
},
|
||||||
|
[interaction, onActiveChange],
|
||||||
|
);
|
||||||
|
const handlePressOut = useCallback(
|
||||||
|
(_event: GestureResponderEvent) => {
|
||||||
|
onActiveChange?.(false);
|
||||||
|
},
|
||||||
|
[onActiveChange],
|
||||||
|
);
|
||||||
let trigger: ReactElement;
|
let trigger: ReactElement;
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
trigger = (
|
trigger = (
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
testID={testID ? `${testID}-toggle` : undefined}
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
style={pressableStyle}
|
style={canvasPressableStyle}
|
||||||
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)}
|
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState, canvasRendered)}
|
||||||
cancelable={false}
|
onPressIn={handlePressIn}
|
||||||
onPressIn={interaction.onPressIn}
|
onPressOut={handlePressOut}
|
||||||
onPressOut={interaction.onPressOut}
|
|
||||||
onLongPress={interaction.onLongPress}
|
onLongPress={interaction.onLongPress}
|
||||||
onContextMenu={interaction.select}
|
onContextMenu={interaction.select}
|
||||||
onPress={interaction.activate}
|
onPress={interaction.activate}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
|
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
>
|
>
|
||||||
{content}
|
{renderedContent}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>;
|
trigger = (
|
||||||
|
<View
|
||||||
|
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
>
|
||||||
|
{renderedContent}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[styles.container, fileHeaderContainerStyle(showsBodyState)]}
|
style={[
|
||||||
|
styles.container,
|
||||||
|
fileHeaderContainerStyle(showsBodyState),
|
||||||
|
canvasRendered && styles.canvasInteraction,
|
||||||
|
]}
|
||||||
onLayout={interaction.onLayout}
|
onLayout={interaction.onLayout}
|
||||||
onPointerEnter={hover.handlePointerEnter}
|
onPointerEnter={hover.handlePointerEnter}
|
||||||
onPointerLeave={hover.handlePointerLeave}
|
onPointerLeave={hover.handlePointerLeave}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
>
|
>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||||
@@ -347,6 +400,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
documentActive: { backgroundColor: theme.colors.surface1 },
|
documentActive: { backgroundColor: theme.colors.surface1 },
|
||||||
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
|
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
|
||||||
|
canvasInteraction: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: "transparent",
|
||||||
|
elevation: 0,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
canvasInteractionContent: { flex: 1, minWidth: 0 },
|
||||||
left: {
|
left: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -28,3 +28,11 @@ export function resolveKeyboardShift(input: {
|
|||||||
|
|
||||||
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
|
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldReconcileHiddenKeyboardEnd(input: {
|
||||||
|
height: number;
|
||||||
|
progress: number;
|
||||||
|
}): boolean {
|
||||||
|
"worklet";
|
||||||
|
return !(input.height > 0) || !(input.progress > 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
shouldReconcileHiddenKeyboardEnd,
|
||||||
resolveKeyboardShift,
|
resolveKeyboardShift,
|
||||||
shouldUseCompactExplorerKeyboardPadding,
|
shouldUseCompactExplorerKeyboardPadding,
|
||||||
} from "./keyboard-shift-policy";
|
} from "./keyboard-shift-policy";
|
||||||
@@ -42,6 +43,23 @@ describe("resolveKeyboardShift", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldReconcileHiddenKeyboardEnd", () => {
|
||||||
|
it("closes stale iOS keyboard state without letting a late visible end resurrect it", () => {
|
||||||
|
expect(
|
||||||
|
shouldReconcileHiddenKeyboardEnd({
|
||||||
|
height: 0,
|
||||||
|
progress: 0,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldReconcileHiddenKeyboardEnd({
|
||||||
|
height: 320,
|
||||||
|
progress: 1,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("shouldUseCompactExplorerKeyboardPadding", () => {
|
describe("shouldUseCompactExplorerKeyboardPadding", () => {
|
||||||
it("keeps the changes viewport stable while preserving padding for other tabs", () => {
|
it("keeps the changes viewport stable while preserving padding for other tabs", () => {
|
||||||
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe(
|
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||||
resolveKeyboardShift,
|
resolveKeyboardShift,
|
||||||
|
shouldReconcileHiddenKeyboardEnd,
|
||||||
} from "@/hooks/keyboard-shift-policy";
|
} from "@/hooks/keyboard-shift-policy";
|
||||||
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||||
|
|
||||||
@@ -34,9 +35,9 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
{
|
{
|
||||||
onEnd: (event) => {
|
onEnd: (event) => {
|
||||||
"worklet";
|
"worklet";
|
||||||
if (isIos) {
|
if (isIos && shouldReconcileHiddenKeyboardEnd(event)) {
|
||||||
keyboardHeight.value = -event.height;
|
keyboardHeight.value = 0;
|
||||||
keyboardProgress.value = event.progress;
|
keyboardProgress.value = 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
|
startedRevision.value = beginGesture({ origin: "agent" });
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
||||||
@@ -188,7 +188,6 @@ export function useCloseAgentListGesture() {
|
|||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({
|
startedRevision.value = beginGesture({
|
||||||
origin: "agent-list",
|
origin: "agent-list",
|
||||||
preview: "agent-list",
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
@@ -290,7 +289,6 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
|
|||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({
|
startedRevision.value = beginGesture({
|
||||||
origin: "agent",
|
origin: "agent",
|
||||||
preview: "file-explorer",
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
@@ -386,7 +384,6 @@ export function useCloseFileExplorerGesture() {
|
|||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({
|
startedRevision.value = beginGesture({
|
||||||
origin: "file-explorer",
|
origin: "file-explorer",
|
||||||
preview: "file-explorer",
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
canBeginMobilePanelGesture,
|
canBeginMobilePanelGesture,
|
||||||
createMobilePanelMotionState,
|
createMobilePanelMotionState,
|
||||||
getMobilePanelFrame,
|
getMobilePanelFrame,
|
||||||
|
isMobilePanelActive,
|
||||||
isMobilePanelGestureCurrent,
|
isMobilePanelGestureCurrent,
|
||||||
transitionMobilePanel,
|
transitionMobilePanel,
|
||||||
type MobilePanelCommit,
|
type MobilePanelCommit,
|
||||||
@@ -53,8 +54,8 @@ class MobilePanelsScenario {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
|
settleAt(position: number) {
|
||||||
this.dispatch({ type: "animation.finished", revision, target });
|
this.dispatch({ type: "position.changed", position });
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +78,107 @@ class MobilePanelsScenario {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("mobile panel ownership", () => {
|
describe("mobile panel ownership", () => {
|
||||||
|
it("publishes activity only after motion settles", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||||
|
const dragging = transitionMobilePanel(initial, {
|
||||||
|
type: "gesture.begin",
|
||||||
|
origin: "agent",
|
||||||
|
}).state;
|
||||||
|
const released = transitionMobilePanel(dragging, {
|
||||||
|
type: "gesture.finish",
|
||||||
|
startedRevision: 0,
|
||||||
|
success: true,
|
||||||
|
target: "agent-list",
|
||||||
|
}).state;
|
||||||
|
const commandTransition = transitionMobilePanel(released, {
|
||||||
|
type: "command",
|
||||||
|
selection: { target: "agent-list", revision: 1 },
|
||||||
|
});
|
||||||
|
const commanded = commandTransition.state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(dragging, "agent-list")).toBe(false);
|
||||||
|
expect(isMobilePanelActive(released, "agent-list")).toBe(false);
|
||||||
|
expect(isMobilePanelActive(commanded, "agent-list")).toBe(false);
|
||||||
|
expect(commandTransition.animationTarget).toBeUndefined();
|
||||||
|
|
||||||
|
const settled = transitionMobilePanel(commanded, {
|
||||||
|
type: "position.changed",
|
||||||
|
position: -1,
|
||||||
|
}).state;
|
||||||
|
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates immediately when the command arrives after position settles", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||||
|
const dragging = transitionMobilePanel(initial, {
|
||||||
|
type: "gesture.begin",
|
||||||
|
origin: "agent",
|
||||||
|
}).state;
|
||||||
|
const released = transitionMobilePanel(dragging, {
|
||||||
|
type: "gesture.finish",
|
||||||
|
startedRevision: 0,
|
||||||
|
success: true,
|
||||||
|
target: "agent-list",
|
||||||
|
}).state;
|
||||||
|
const reachedAnchor = transitionMobilePanel(released, {
|
||||||
|
type: "position.changed",
|
||||||
|
position: -1,
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(reachedAnchor, "agent-list")).toBe(false);
|
||||||
|
|
||||||
|
const commanded = transitionMobilePanel(reachedAnchor, {
|
||||||
|
type: "command",
|
||||||
|
selection: { target: "agent-list", revision: 1 },
|
||||||
|
});
|
||||||
|
expect(commanded.animationTarget).toBeUndefined();
|
||||||
|
|
||||||
|
const settled = transitionMobilePanel(commanded.state, {
|
||||||
|
type: "position.changed",
|
||||||
|
position: -1,
|
||||||
|
}).state;
|
||||||
|
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change activity for a cancelled preview", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||||
|
const dragging = transitionMobilePanel(initial, {
|
||||||
|
type: "gesture.begin",
|
||||||
|
origin: "agent",
|
||||||
|
}).state;
|
||||||
|
const cancelled = transitionMobilePanel(dragging, {
|
||||||
|
type: "gesture.finish",
|
||||||
|
startedRevision: 0,
|
||||||
|
success: false,
|
||||||
|
target: "agent-list",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(dragging, "agent")).toBe(true);
|
||||||
|
expect(isMobilePanelActive(cancelled, "agent")).toBe(true);
|
||||||
|
expect(isMobilePanelActive(cancelled, "agent-list")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the visible panel active until its closing motion settles", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent-list", revision: 0 });
|
||||||
|
const closing = transitionMobilePanel(initial, {
|
||||||
|
type: "command",
|
||||||
|
selection: { target: "agent", revision: 1 },
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(closing, "agent-list")).toBe(true);
|
||||||
|
|
||||||
|
const settled = transitionMobilePanel(closing, {
|
||||||
|
type: "position.changed",
|
||||||
|
position: 0,
|
||||||
|
}).state;
|
||||||
|
expect(isMobilePanelActive(settled, "agent-list")).toBe(false);
|
||||||
|
expect(isMobilePanelActive(settled, "agent")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("follows programmatic commands through left, center, and right", () => {
|
it("follows programmatic commands through left, center, and right", () => {
|
||||||
const panels = new MobilePanelsScenario();
|
const panels = new MobilePanelsScenario();
|
||||||
|
|
||||||
panels.command("agent-list").finishAnimation("agent-list");
|
panels.command("agent-list").settleAt(-1);
|
||||||
expect(panels.snapshot()).toEqual({
|
expect(panels.snapshot()).toEqual({
|
||||||
target: "agent-list",
|
target: "agent-list",
|
||||||
motionTarget: "agent-list",
|
motionTarget: "agent-list",
|
||||||
@@ -88,7 +186,7 @@ describe("mobile panel ownership", () => {
|
|||||||
revision: 1,
|
revision: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
|
panels.command("agent").command("file-explorer").settleAt(1);
|
||||||
expect(panels.snapshot()).toEqual({
|
expect(panels.snapshot()).toEqual({
|
||||||
target: "file-explorer",
|
target: "file-explorer",
|
||||||
motionTarget: "file-explorer",
|
motionTarget: "file-explorer",
|
||||||
@@ -139,13 +237,12 @@ describe("mobile panel ownership", () => {
|
|||||||
expect(panels.commits).toEqual([]);
|
expect(panels.commits).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the latest rapid command and rejects stale animation completion", () => {
|
it("keeps the latest rapid command when position reaches a stale anchor", () => {
|
||||||
const panels = new MobilePanelsScenario();
|
const panels = new MobilePanelsScenario();
|
||||||
|
|
||||||
panels.command("agent-list");
|
panels.command("agent-list");
|
||||||
const staleRevision = panels.snapshot().revision;
|
|
||||||
panels.command("agent").command("file-explorer");
|
panels.command("agent").command("file-explorer");
|
||||||
panels.finishAnimation("agent-list", staleRevision);
|
panels.settleAt(-1);
|
||||||
|
|
||||||
expect(panels.snapshot()).toEqual({
|
expect(panels.snapshot()).toEqual({
|
||||||
target: "file-explorer",
|
target: "file-explorer",
|
||||||
|
|||||||
@@ -21,6 +21,32 @@ export interface MobilePanelTransition {
|
|||||||
state: MobilePanelMotionState;
|
state: MobilePanelMotionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMobilePanelAnchor(panel: MobilePanelView): number {
|
||||||
|
"worklet";
|
||||||
|
if (panel === "agent-list") {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (panel === "file-explorer") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleMobilePanelAtPosition(
|
||||||
|
state: MobilePanelMotionState,
|
||||||
|
position: number,
|
||||||
|
): MobilePanelTransition {
|
||||||
|
"worklet";
|
||||||
|
const isCanonicalMotion = state.target === state.motionTarget;
|
||||||
|
const isAtTarget = Math.abs(position - getMobilePanelAnchor(state.target)) <= 0.002;
|
||||||
|
if (state.gesture || !isCanonicalMotion || !isAtTarget || state.settledTarget === state.target) {
|
||||||
|
return { state };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: { ...state, settledTarget: state.target },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type MobilePanelEvent =
|
export type MobilePanelEvent =
|
||||||
| { type: "command"; selection: MobilePanelSelection }
|
| { type: "command"; selection: MobilePanelSelection }
|
||||||
| { type: "gesture.begin"; origin: MobilePanelView }
|
| { type: "gesture.begin"; origin: MobilePanelView }
|
||||||
@@ -30,7 +56,7 @@ export type MobilePanelEvent =
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
target: MobilePanelView;
|
target: MobilePanelView;
|
||||||
}
|
}
|
||||||
| { type: "animation.finished"; revision: number; target: MobilePanelView };
|
| { type: "position.changed"; position: number };
|
||||||
|
|
||||||
export function createMobilePanelMotionState(
|
export function createMobilePanelMotionState(
|
||||||
selection: MobilePanelSelection,
|
selection: MobilePanelSelection,
|
||||||
@@ -52,8 +78,9 @@ export function transitionMobilePanel(
|
|||||||
if (event.selection.revision <= state.revision) {
|
if (event.selection.revision <= state.revision) {
|
||||||
return { state };
|
return { state };
|
||||||
}
|
}
|
||||||
|
const continuesCurrentMotion = state.motionTarget === event.selection.target;
|
||||||
return {
|
return {
|
||||||
animationTarget: event.selection.target,
|
animationTarget: continuesCurrentMotion ? undefined : event.selection.target,
|
||||||
state: {
|
state: {
|
||||||
...state,
|
...state,
|
||||||
...event.selection,
|
...event.selection,
|
||||||
@@ -101,26 +128,7 @@ export function transitionMobilePanel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownsCurrentRevision = event.revision === state.revision;
|
return settleMobilePanelAtPosition(state, event.position);
|
||||||
const isCanonicalTarget = event.target === state.target;
|
|
||||||
const isCurrentMotionTarget = event.target === state.motionTarget;
|
|
||||||
if (!ownsCurrentRevision || !isCanonicalTarget || !isCurrentMotionTarget) {
|
|
||||||
return { state };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: { ...state, settledTarget: event.target },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMobilePanelAnchor(panel: MobilePanelView): number {
|
|
||||||
"worklet";
|
|
||||||
if (panel === "agent-list") {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (panel === "file-explorer") {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canBeginMobilePanelGesture(
|
export function canBeginMobilePanelGesture(
|
||||||
@@ -143,6 +151,13 @@ export function isMobilePanelGestureCurrent(
|
|||||||
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
|
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMobilePanelActive(
|
||||||
|
state: MobilePanelMotionState,
|
||||||
|
panel: MobilePanelView,
|
||||||
|
): boolean {
|
||||||
|
return state.settledTarget === panel;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMobilePanelFrame(position: number, width: number) {
|
export function getMobilePanelFrame(position: number, width: number) {
|
||||||
"worklet";
|
"worklet";
|
||||||
const clampedPosition = Math.max(-1, Math.min(1, position));
|
const clampedPosition = Math.max(-1, Math.min(1, position));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { isWeb } from "@/constants/platform";
|
|||||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||||
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
||||||
import { getMobilePanelFrame } from "./model";
|
import { getMobilePanelFrame } from "./model";
|
||||||
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
|
import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider";
|
||||||
|
|
||||||
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
||||||
|
|
||||||
@@ -24,10 +24,8 @@ export function MobilePanelOverlay({
|
|||||||
panelStyle,
|
panelStyle,
|
||||||
}: MobilePanelOverlayProps) {
|
}: MobilePanelOverlayProps) {
|
||||||
const { position, windowWidth } = useMobilePanelsRuntime();
|
const { position, windowWidth } = useMobilePanelsRuntime();
|
||||||
const target = usePanelStore((state) => state.mobilePanel.target);
|
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const isOpen = target === panel;
|
const isOpen = useIsMobilePanelActive(panel);
|
||||||
const isPresented = useIsMobilePanelPresented(panel);
|
|
||||||
const isLeft = panel === "agent-list";
|
const isLeft = panel === "agent-list";
|
||||||
|
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -42,10 +40,11 @@ export function MobilePanelOverlay({
|
|||||||
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
||||||
}, [isLeft, windowWidth]);
|
}, [isLeft, windowWidth]);
|
||||||
|
|
||||||
const overlayStyle = useMemo(
|
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||||
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
|
const isVisible = isLeft ? position.value < 0 : position.value > 0;
|
||||||
[isPresented],
|
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||||
);
|
}, [isLeft]);
|
||||||
|
|
||||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||||
const backdropStyle = useMemo(
|
const backdropStyle = useMemo(
|
||||||
() => [styles.backdrop, backdropAnimatedStyle],
|
() => [styles.backdrop, backdropAnimatedStyle],
|
||||||
@@ -73,19 +72,25 @@ export function MobilePanelOverlay({
|
|||||||
{/* Fabric needs an always-mounted native host to attach the close handler while the
|
{/* Fabric needs an always-mounted native host to attach the close handler while the
|
||||||
retained panel content is hidden. nativeID keeps that host registered. */}
|
retained panel content is hidden. nativeID keeps that host registered. */}
|
||||||
<View
|
<View
|
||||||
|
accessibilityElementsHidden={!isOpen}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
|
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
|
||||||
nativeID={`${panel}-gesture-host`}
|
nativeID={`${panel}-gesture-host`}
|
||||||
pointerEvents={overlayPointerEvents}
|
pointerEvents={overlayPointerEvents}
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
>
|
>
|
||||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
<Animated.View
|
||||||
|
pointerEvents={overlayPointerEvents}
|
||||||
|
style={[styles.overlay, overlayAnimatedStyle]}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible={false}
|
||||||
accessibilityElementsHidden
|
accessibilityElementsHidden
|
||||||
importantForAccessibility="no-hide-descendants"
|
importantForAccessibility="no-hide-descendants"
|
||||||
onPress={showMobileAgent}
|
onPress={showMobileAgent}
|
||||||
pointerEvents={isOpen ? "auto" : "none"}
|
pointerEvents={isOpen ? "auto" : "none"}
|
||||||
style={StyleSheet.absoluteFillObject}
|
style={StyleSheet.absoluteFillObject}
|
||||||
testID={`${panel}-backdrop`}
|
testID={isOpen ? `${panel}-backdrop` : undefined}
|
||||||
>
|
>
|
||||||
<Animated.View pointerEvents="none" style={backdropStyle} />
|
<Animated.View pointerEvents="none" style={backdropStyle} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -93,7 +98,7 @@ export function MobilePanelOverlay({
|
|||||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||||
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { GestureType } from "react-native-gesture-handler";
|
|||||||
import {
|
import {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
Easing,
|
Easing,
|
||||||
|
useAnimatedReaction,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
@@ -39,18 +40,6 @@ import {
|
|||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
const LEFT_PANEL_MASK = 1;
|
|
||||||
const RIGHT_PANEL_MASK = 2;
|
|
||||||
|
|
||||||
function getPanelMask(panel: MobilePanelView): number {
|
|
||||||
if (panel === "agent-list") {
|
|
||||||
return LEFT_PANEL_MASK;
|
|
||||||
}
|
|
||||||
if (panel === "file-explorer") {
|
|
||||||
return RIGHT_PANEL_MASK;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MobilePanelsRuntime {
|
interface MobilePanelsRuntime {
|
||||||
beginGesture: (input: BeginGestureInput) => number;
|
beginGesture: (input: BeginGestureInput) => number;
|
||||||
@@ -69,7 +58,6 @@ interface MobilePanelsRuntime {
|
|||||||
|
|
||||||
interface BeginGestureInput {
|
interface BeginGestureInput {
|
||||||
origin: MobilePanelView;
|
origin: MobilePanelView;
|
||||||
preview: MobilePanelView;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FinishGestureInput {
|
interface FinishGestureInput {
|
||||||
@@ -79,7 +67,7 @@ interface FinishGestureInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||||
const MobilePanelPresentationContext = createContext(0);
|
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
|
||||||
|
|
||||||
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
@@ -92,7 +80,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
|
const [activePanel, setActivePanel] = useState(initialSelection.target);
|
||||||
|
|
||||||
const setOpenGestureBlocked = useCallback(
|
const setOpenGestureBlocked = useCallback(
|
||||||
(owner: symbol, blocked: boolean) => {
|
(owner: symbol, blocked: boolean) => {
|
||||||
@@ -106,21 +94,33 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
[openGesturesBlocked],
|
[openGesturesBlocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
const presentPanel = useCallback((panel: MobilePanelView) => {
|
const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => {
|
||||||
const mask = getPanelMask(panel);
|
|
||||||
if (mask) {
|
|
||||||
setPresentedPanels((current) => current | mask);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
|
|
||||||
const selection = usePanelStore.getState().mobilePanel;
|
const selection = usePanelStore.getState().mobilePanel;
|
||||||
if (selection.revision !== revision || selection.target !== panel) {
|
if (selection.revision !== revision || selection.target !== panel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPresentedPanels(getPanelMask(panel));
|
if (isNative && panel !== "agent") {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
setActivePanel(panel);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({ motionState: motionState.value, position: position.value }),
|
||||||
|
({ motionState: currentState, position: currentPosition }) => {
|
||||||
|
const settled = transitionMobilePanel(currentState, {
|
||||||
|
type: "position.changed",
|
||||||
|
position: currentPosition,
|
||||||
|
});
|
||||||
|
if (settled.state === currentState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
motionState.value = settled.state;
|
||||||
|
scheduleOnRN(publishActivePanel, settled.state.settledTarget, settled.state.revision);
|
||||||
|
},
|
||||||
|
[motionState, position, publishActivePanel],
|
||||||
|
);
|
||||||
|
|
||||||
const animateTransition = useCallback(
|
const animateTransition = useCallback(
|
||||||
(transition: MobilePanelTransition) => {
|
(transition: MobilePanelTransition) => {
|
||||||
"worklet";
|
"worklet";
|
||||||
@@ -128,29 +128,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = transition.animationTarget;
|
const target = transition.animationTarget;
|
||||||
const revision = transition.state.revision;
|
position.value = withTiming(getMobilePanelAnchor(target), {
|
||||||
position.value = withTiming(
|
duration: ANIMATION_DURATION,
|
||||||
getMobilePanelAnchor(target),
|
easing: ANIMATION_EASING,
|
||||||
{ duration: ANIMATION_DURATION, easing: ANIMATION_EASING },
|
});
|
||||||
(finished) => {
|
|
||||||
if (!finished) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentState = motionState.value;
|
|
||||||
const settled = transitionMobilePanel(currentState, {
|
|
||||||
type: "animation.finished",
|
|
||||||
revision,
|
|
||||||
target,
|
|
||||||
});
|
|
||||||
if (settled.state === currentState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
motionState.value = settled.state;
|
|
||||||
scheduleOnRN(settlePresentation, target, revision);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[motionState, position, settlePresentation],
|
[position],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applySelection = useCallback(
|
const applySelection = useCallback(
|
||||||
@@ -176,18 +159,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
if (selection === previousState.mobilePanel) {
|
if (selection === previousState.mobilePanel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selection.target !== "agent") {
|
|
||||||
presentPanel(selection.target);
|
|
||||||
if (isNative) {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scheduleOnUI(applySelection, selection);
|
scheduleOnUI(applySelection, selection);
|
||||||
});
|
});
|
||||||
}, [applySelection, presentPanel]);
|
}, [applySelection]);
|
||||||
|
|
||||||
const beginGesture = useCallback(
|
const beginGesture = useCallback(
|
||||||
({ origin, preview }: BeginGestureInput): number => {
|
({ origin }: BeginGestureInput): number => {
|
||||||
"worklet";
|
"worklet";
|
||||||
const currentState = motionState.value;
|
const currentState = motionState.value;
|
||||||
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
||||||
@@ -199,10 +176,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
motionState.value = transition.state;
|
motionState.value = transition.state;
|
||||||
cancelAnimation(position);
|
cancelAnimation(position);
|
||||||
scheduleOnRN(presentPanel, preview);
|
|
||||||
return transition.state.gesture?.startedRevision ?? -1;
|
return transition.state.gesture?.startedRevision ?? -1;
|
||||||
},
|
},
|
||||||
[motionState, position, presentPanel],
|
[motionState, position],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateGesture = useCallback(
|
const updateGesture = useCallback(
|
||||||
@@ -266,9 +242,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobilePanelsContext.Provider value={value}>
|
<MobilePanelsContext.Provider value={value}>
|
||||||
<MobilePanelPresentationContext.Provider value={presentedPanels}>
|
<MobilePanelActiveContext.Provider value={activePanel}>
|
||||||
{children}
|
{children}
|
||||||
</MobilePanelPresentationContext.Provider>
|
</MobilePanelActiveContext.Provider>
|
||||||
</MobilePanelsContext.Provider>
|
</MobilePanelsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,9 +258,8 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
|
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
|
||||||
const presentedPanels = useContext(MobilePanelPresentationContext);
|
return useContext(MobilePanelActiveContext) === panel;
|
||||||
return (presentedPanels & getPanelMask(panel)) !== 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
|
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Buffer } from "buffer";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
AgentStatusSchema,
|
AgentStatusSchema,
|
||||||
@@ -715,8 +714,32 @@ function pendingRowKey(key: ReplicaRowKey): string {
|
|||||||
return `${key.serverId}\u0000${rowKey(key)}`;
|
return `${key.serverId}\u0000${rowKey(key)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function payloadBytes(payload: string): number {
|
// Budget accounting runs over every stored row of a touched host on each persist. Rows are
|
||||||
return Buffer.byteLength(payload, "utf8");
|
// immutable once stored, so their size is computed once. The count itself avoids the JS Buffer
|
||||||
|
// polyfill, which materialises the whole byte array just to measure it.
|
||||||
|
const rowBytesCache = new WeakMap<ReplicaRow, number>();
|
||||||
|
|
||||||
|
function utf8ByteLength(text: string): number {
|
||||||
|
let bytes = 0;
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const code = text.charCodeAt(index);
|
||||||
|
if (code < 0x80) bytes += 1;
|
||||||
|
else if (code < 0x800) bytes += 2;
|
||||||
|
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
bytes += 4;
|
||||||
|
index += 1;
|
||||||
|
} else bytes += 3;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBytes(row: ReplicaRow): number {
|
||||||
|
let bytes = rowBytesCache.get(row);
|
||||||
|
if (bytes === undefined) {
|
||||||
|
bytes = utf8ByteLength(row.payload);
|
||||||
|
rowBytesCache.set(row, bytes);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonPayload(payload: string): unknown {
|
function parseJsonPayload(payload: string): unknown {
|
||||||
@@ -1053,7 +1076,7 @@ export class ReplicaCache {
|
|||||||
this.storedRows.delete(oldServerId);
|
this.storedRows.delete(oldServerId);
|
||||||
for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId });
|
for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId });
|
||||||
this.storedRows.set(newServerId, newRows);
|
this.storedRows.set(newServerId, newRows);
|
||||||
const bytes = [...newRows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0);
|
const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0);
|
||||||
this.hostBytes.delete(oldServerId);
|
this.hostBytes.delete(oldServerId);
|
||||||
this.hostBytes.set(newServerId, bytes);
|
this.hostBytes.set(newServerId, bytes);
|
||||||
this.totalBytes += bytes;
|
this.totalBytes += bytes;
|
||||||
@@ -1237,17 +1260,17 @@ export class ReplicaCache {
|
|||||||
const previous = rows?.get(rowKey(key));
|
const previous = rows?.get(rowKey(key));
|
||||||
if (previous) {
|
if (previous) {
|
||||||
rows?.delete(rowKey(key));
|
rows?.delete(rowKey(key));
|
||||||
this.adjustHostBytes(key.serverId, -payloadBytes(previous.payload));
|
this.adjustHostBytes(key.serverId, -rowBytes(previous));
|
||||||
}
|
}
|
||||||
touchedServerIds.add(key.serverId);
|
touchedServerIds.add(key.serverId);
|
||||||
}
|
}
|
||||||
for (const row of changes.upserts) {
|
for (const row of changes.upserts) {
|
||||||
const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>();
|
const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>();
|
||||||
const previous = rows.get(rowKey(row));
|
const previous = rows.get(rowKey(row));
|
||||||
const previousBytes = previous ? payloadBytes(previous.payload) : 0;
|
const previousBytes = previous ? rowBytes(previous) : 0;
|
||||||
rows.set(rowKey(row), row);
|
rows.set(rowKey(row), row);
|
||||||
this.storedRows.set(row.serverId, rows);
|
this.storedRows.set(row.serverId, rows);
|
||||||
this.adjustHostBytes(row.serverId, payloadBytes(row.payload) - previousBytes);
|
this.adjustHostBytes(row.serverId, rowBytes(row) - previousBytes);
|
||||||
touchedServerIds.add(row.serverId);
|
touchedServerIds.add(row.serverId);
|
||||||
}
|
}
|
||||||
for (const serverId of touchedServerIds) {
|
for (const serverId of touchedServerIds) {
|
||||||
@@ -1288,7 +1311,7 @@ export class ReplicaCache {
|
|||||||
for (const [serverId, rows] of projectedRows) {
|
for (const [serverId, rows] of projectedRows) {
|
||||||
projectedBytes.set(
|
projectedBytes.set(
|
||||||
serverId,
|
serverId,
|
||||||
[...rows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0),
|
[...rows.values()].reduce((sum, row) => sum + rowBytes(row), 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,7 +1394,7 @@ export class ReplicaCache {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const rows = new Map(host.rows.map((row) => [rowKey(row), row]));
|
const rows = new Map(host.rows.map((row) => [rowKey(row), row]));
|
||||||
const bytes = host.rows.reduce((sum, row) => sum + payloadBytes(row.payload), 0);
|
const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0);
|
||||||
this.storedRows.set(host.serverId, rows);
|
this.storedRows.set(host.serverId, rows);
|
||||||
this.hostBytes.set(host.serverId, bytes);
|
this.hostBytes.set(host.serverId, bytes);
|
||||||
this.totalBytes += bytes;
|
this.totalBytes += bytes;
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ describe("deriveStreamTurnTiming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
||||||
startedAt: userAt,
|
|
||||||
completedAt: assistantAt,
|
completedAt: assistantAt,
|
||||||
durationMs: 7000,
|
durationMs: 7000,
|
||||||
});
|
});
|
||||||
@@ -93,11 +92,37 @@ describe("deriveStreamTurnTiming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
startedAt: userAt,
|
|
||||||
completedAt: lastAssistantAt,
|
completedAt: lastAssistantAt,
|
||||||
durationMs: 7000,
|
durationMs: 7000,
|
||||||
};
|
};
|
||||||
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
||||||
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
|
||||||
|
const firstTurnAt = new Date("2026-05-15T00:00:00.000Z");
|
||||||
|
const hiddenPromptTurnAt = new Date("2026-05-15T00:01:07.000Z");
|
||||||
|
const timing = deriveStreamTurnTiming({
|
||||||
|
isTurnActive: false,
|
||||||
|
activeTurnStartedAt: null,
|
||||||
|
tail: [
|
||||||
|
{ ...user("u1", firstTurnAt), turnId: "turn-1" },
|
||||||
|
{
|
||||||
|
...assistant("a1", new Date("2026-05-15T00:00:07.000Z")),
|
||||||
|
turnId: "turn-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...assistant("hidden-prompt-a1", new Date("2026-05-15T00:01:03.000Z")),
|
||||||
|
turnId: "turn-2",
|
||||||
|
},
|
||||||
|
{ ...assistant("hidden-prompt-a2", hiddenPromptTurnAt), turnId: "turn-2" },
|
||||||
|
],
|
||||||
|
head: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
|
||||||
|
completedAt: hiddenPromptTurnAt,
|
||||||
|
durationMs: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import type { StreamItem } from "@/types/stream";
|
|||||||
import { startsNewTurn } from "@/agent-stream/turn-membership";
|
import { startsNewTurn } from "@/agent-stream/turn-membership";
|
||||||
|
|
||||||
export interface TurnTiming {
|
export interface TurnTiming {
|
||||||
startedAt: Date;
|
|
||||||
completedAt: Date;
|
completedAt: Date;
|
||||||
durationMs: number;
|
durationMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamTurnTiming {
|
export interface StreamTurnTiming {
|
||||||
@@ -25,13 +24,14 @@ export function deriveStreamTurnTiming(params: {
|
|||||||
let previousItem: StreamItem | null = null;
|
let previousItem: StreamItem | null = null;
|
||||||
|
|
||||||
const flushCompletedTurn = () => {
|
const flushCompletedTurn = () => {
|
||||||
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
|
if (!currentLastItemAt || currentAssistantIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timing: TurnTiming = {
|
const timing: TurnTiming = {
|
||||||
startedAt: currentUserAt,
|
|
||||||
completedAt: currentLastItemAt,
|
completedAt: currentLastItemAt,
|
||||||
durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()),
|
durationMs: currentUserAt
|
||||||
|
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
for (const id of currentAssistantIds) {
|
for (const id of currentAssistantIds) {
|
||||||
byAssistantId.set(id, timing);
|
byAssistantId.set(id, timing);
|
||||||
@@ -45,10 +45,6 @@ export function deriveStreamTurnTiming(params: {
|
|||||||
currentLastItemAt = null;
|
currentLastItemAt = null;
|
||||||
currentAssistantIds = [];
|
currentAssistantIds = [];
|
||||||
}
|
}
|
||||||
if (!currentUserAt) {
|
|
||||||
previousItem = item;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentLastItemAt = item.timestamp;
|
currentLastItemAt = item.timestamp;
|
||||||
if (item.kind === "assistant_message") {
|
if (item.kind === "assistant_message") {
|
||||||
currentAssistantIds.push(item.id);
|
currentAssistantIds.push(item.id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@getpaseo/server": "0.7.0",
|
"@getpaseo/server": "0.7.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ describe("plugin scaffold", () => {
|
|||||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
||||||
id: "hello-plugin",
|
id: "hello-plugin",
|
||||||
});
|
});
|
||||||
|
const cliPackageJson = JSON.parse(
|
||||||
|
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
|
||||||
|
) as { version: string };
|
||||||
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
|
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
|
||||||
name: "hello-plugin",
|
name: "hello-plugin",
|
||||||
private: true,
|
private: true,
|
||||||
version: "0.0.0",
|
version: "0.0.0",
|
||||||
scripts: { typecheck: "tsc --noEmit" },
|
scripts: { typecheck: "tsc --noEmit" },
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
"@getpaseo/plugin": "0.7.0",
|
"@getpaseo/plugin": cliPackageJson.version,
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
react: "19.1.0",
|
react: "19.1.0",
|
||||||
|
|||||||
@@ -57,10 +57,15 @@ const EXPECTED_CLAUDE_MODELS = [
|
|||||||
model: "Opus 5",
|
model: "Opus 5",
|
||||||
descriptionFragment: "Latest release",
|
descriptionFragment: "Latest release",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "claude-fable-5-1",
|
||||||
|
model: "Fable 5.1",
|
||||||
|
descriptionFragment: "Most powerful",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "claude-fable-5",
|
id: "claude-fable-5",
|
||||||
model: "Fable 5",
|
model: "Fable 5",
|
||||||
descriptionFragment: "Most powerful",
|
descriptionFragment: "Previous release",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "claude-opus-4-8[1m]",
|
id: "claude-opus-4-8[1m]",
|
||||||
@@ -161,18 +166,9 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertClaudeModels(data: ProviderModel[]): void {
|
function assertClaudeModels(data: ProviderModel[]): void {
|
||||||
assert.strictEqual(
|
|
||||||
data.length,
|
|
||||||
EXPECTED_CLAUDE_CATALOG_MODELS.length,
|
|
||||||
"claude output should match the current catalog size",
|
|
||||||
);
|
|
||||||
|
|
||||||
const byId = new Map(data.map((model) => [model.id, model]));
|
const byId = new Map(data.map((model) => [model.id, model]));
|
||||||
const ids = [...byId.keys()].sort();
|
|
||||||
const expectedIds = EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort();
|
|
||||||
|
|
||||||
assert.strictEqual(byId.size, data.length, "claude model IDs should be unique");
|
assert.strictEqual(byId.size, data.length, "claude model IDs should be unique");
|
||||||
assert.deepStrictEqual(ids, expectedIds, "claude IDs should match the current catalog");
|
|
||||||
|
|
||||||
for (const expectedModel of EXPECTED_CLAUDE_CATALOG_MODELS) {
|
for (const expectedModel of EXPECTED_CLAUDE_CATALOG_MODELS) {
|
||||||
const actualModel = byId.get(expectedModel.id);
|
const actualModel = byId.get(expectedModel.id);
|
||||||
@@ -187,6 +183,18 @@ function assertClaudeModels(data: ProviderModel[]): void {
|
|||||||
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`,
|
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fable51Index = data.findIndex((model) => model.id === "claude-fable-5-1");
|
||||||
|
const fable5Index = data.findIndex((model) => model.id === "claude-fable-5");
|
||||||
|
assert.strictEqual(
|
||||||
|
fable5Index,
|
||||||
|
fable51Index + 1,
|
||||||
|
"Fable models should stay adjacent and newest-first",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!byId.has("claude-fable-5[1m]"),
|
||||||
|
"compatibility-only Fable aliases should not appear in CLI output",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -411,20 +419,10 @@ try {
|
|||||||
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
|
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
|
||||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||||
assert.strictEqual(
|
|
||||||
lines.length,
|
|
||||||
EXPECTED_CLAUDE_CATALOG_MODELS.length,
|
|
||||||
"should have one line per Claude catalog model",
|
|
||||||
);
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
[...lines].sort(),
|
lines,
|
||||||
[...claudeModelIdsFromJson].sort(),
|
claudeModelIdsFromJson,
|
||||||
"--quiet should print the same model IDs returned by --json",
|
"--quiet should print the same ordered model IDs returned by --json",
|
||||||
);
|
|
||||||
assert.deepStrictEqual(
|
|
||||||
[...lines].sort(),
|
|
||||||
EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort(),
|
|
||||||
"--quiet should print the current Claude catalog IDs",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
||||||
|
|||||||
@@ -218,9 +218,12 @@ try {
|
|||||||
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') &&
|
capturedSupervisorLogs.includes('"msg":"Supervisor requesting graceful worker shutdown"'),
|
||||||
capturedSupervisorLogs.includes('"signal":"SIGTERM"'),
|
`stop should log the graceful worker shutdown request, logs:\n${capturedSupervisorLogs}`,
|
||||||
`stop should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`,
|
);
|
||||||
|
assert(
|
||||||
|
capturedSupervisorLogs.includes("Server closed"),
|
||||||
|
`stop should run daemon cleanup before the worker exits, logs:\n${capturedSupervisorLogs}`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!capturedSupervisorLogs.includes("cli_shutdown"),
|
!capturedSupervisorLogs.includes("cli_shutdown"),
|
||||||
|
|||||||
@@ -234,9 +234,12 @@ try {
|
|||||||
`restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
`restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') &&
|
capturedSupervisorLogs.includes('"msg":"Supervisor requesting graceful worker shutdown"'),
|
||||||
capturedSupervisorLogs.includes('"signal":"SIGTERM"'),
|
`restart should log the graceful worker shutdown request, logs:\n${capturedSupervisorLogs}`,
|
||||||
`restart should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`,
|
);
|
||||||
|
assert(
|
||||||
|
capturedSupervisorLogs.includes("Server closed"),
|
||||||
|
`restart should run daemon cleanup before replacing the worker, logs:\n${capturedSupervisorLogs}`,
|
||||||
);
|
);
|
||||||
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
|
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@getpaseo/relay": "0.7.0",
|
"@getpaseo/relay": "0.7.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Paseo plugin SDK package",
|
"description": "Paseo plugin SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -72,11 +72,11 @@
|
|||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.7.0",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/highlight": "0.7.0",
|
"@getpaseo/highlight": "0.7.2",
|
||||||
"@getpaseo/plugin": "0.7.0",
|
"@getpaseo/plugin": "0.7.2",
|
||||||
"@getpaseo/protocol": "0.7.0",
|
"@getpaseo/protocol": "0.7.2",
|
||||||
"@getpaseo/relay": "0.7.0",
|
"@getpaseo/relay": "0.7.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
|||||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||||
|
|
||||||
|
function isProcessRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runSupervisorFixture(options: {
|
async function runSupervisorFixture(options: {
|
||||||
workerSource: string;
|
workerSource: string;
|
||||||
restartOnCrash?: boolean;
|
restartOnCrash?: boolean;
|
||||||
@@ -172,9 +181,12 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.log).toContain("raw stderr line\n");
|
expect(result.log).toContain("raw stderr line\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logs the worker shutdown reason before signaling the worker", async () => {
|
test("logs the worker shutdown reason before requesting graceful shutdown", async () => {
|
||||||
const result = await runSupervisorFixture({
|
const result = await runSupervisorFixture({
|
||||||
workerSource: `
|
workerSource: `
|
||||||
|
process.on("message", (message) => {
|
||||||
|
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||||
|
});
|
||||||
process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" });
|
process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" });
|
||||||
setInterval(() => {}, 1000);
|
setInterval(() => {}, 1000);
|
||||||
`,
|
`,
|
||||||
@@ -184,17 +196,61 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.signal).toBeNull();
|
expect(result.signal).toBeNull();
|
||||||
expect(result.log).toContain('"msg":"Worker requested shutdown"');
|
expect(result.log).toContain('"msg":"Worker requested shutdown"');
|
||||||
expect(result.log).toContain('"reason":"client_shutdown_rpc"');
|
expect(result.log).toContain('"reason":"client_shutdown_rpc"');
|
||||||
expect(result.log).toContain('"msg":"Supervisor sending signal to worker"');
|
expect(result.log).toContain('"msg":"Supervisor requesting graceful worker shutdown"');
|
||||||
expect(result.log).toContain('"signal":"SIGTERM"');
|
|
||||||
expect(result.log).toContain('"workerPid":');
|
expect(result.log).toContain('"workerPid":');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lets the worker clean up its descendant before supervised shutdown", async () => {
|
||||||
|
const result = await runSupervisorFixture({
|
||||||
|
workerSource: `
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const descendant = spawn(
|
||||||
|
process.execPath,
|
||||||
|
["-e", "setInterval(() => {}, 1000)"],
|
||||||
|
{ detached: true, stdio: "ignore" },
|
||||||
|
);
|
||||||
|
descendant.unref();
|
||||||
|
process.stdout.write(\`DESCENDANT_PID=\${descendant.pid}\\n\`);
|
||||||
|
|
||||||
|
process.on("message", (message) => {
|
||||||
|
if (message?.type !== "paseo:graceful-shutdown") return;
|
||||||
|
descendant.once("exit", () => {
|
||||||
|
process.stdout.write("GRACEFUL_CLEANUP_RAN\\n");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
descendant.kill("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
process.send?.({ type: "paseo:shutdown", reason: "descendant_cleanup_probe" });
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const descendantPid = Number.parseInt(
|
||||||
|
result.stdout.match(/DESCENDANT_PID=(\d+)/)?.[1] ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||||
|
|
||||||
|
const descendantSurvived = isProcessRunning(descendantPid);
|
||||||
|
if (descendantSurvived) {
|
||||||
|
process.kill(descendantPid, "SIGKILL");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect.soft(result.stdout).toContain("GRACEFUL_CLEANUP_RAN");
|
||||||
|
expect(descendantSurvived).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("does not restart a worker based on heartbeat absence", async () => {
|
test("does not restart a worker based on heartbeat absence", async () => {
|
||||||
const result = await runSupervisorFixture({
|
const result = await runSupervisorFixture({
|
||||||
timeoutMs: 20_000,
|
timeoutMs: 20_000,
|
||||||
workerSource: `
|
workerSource: `
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
process.on("message", (message) => {
|
||||||
|
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||||
|
});
|
||||||
const marker = process.argv[1] + ".started";
|
const marker = process.argv[1] + ".started";
|
||||||
if (!existsSync(marker)) {
|
if (!existsSync(marker)) {
|
||||||
writeFileSync(marker, "started");
|
writeFileSync(marker, "started");
|
||||||
@@ -216,26 +272,22 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
||||||
}, 25_000);
|
}, 25_000);
|
||||||
|
|
||||||
test.skipIf(isPlatform("win32"))(
|
test("forces shutdown when a worker ignores the graceful shutdown request", async () => {
|
||||||
"forces shutdown when a worker ignores SIGTERM",
|
const result = await runSupervisorFixture({
|
||||||
async () => {
|
timeoutMs: 15_000,
|
||||||
const result = await runSupervisorFixture({
|
workerSource: `
|
||||||
timeoutMs: 15_000,
|
|
||||||
workerSource: `
|
|
||||||
process.on("SIGTERM", () => {});
|
|
||||||
process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" });
|
process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" });
|
||||||
setInterval(() => {}, 1_000);
|
setInterval(() => {}, 1_000);
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.code).toBe(0);
|
expect(result.code).toBe(0);
|
||||||
expect(result.signal).toBeNull();
|
expect(result.signal).toBeNull();
|
||||||
expect(result.log).toContain('"reason":"stalled_worker_shutdown"');
|
expect(result.log).toContain('"reason":"stalled_worker_shutdown"');
|
||||||
expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"');
|
expect(result.log).toContain(
|
||||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
'"msg":"Worker did not exit after graceful shutdown request; forcing process tree kill"',
|
||||||
},
|
);
|
||||||
20_000,
|
}, 20_000);
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(isPlatform("win32"))(
|
test.skipIf(isPlatform("win32"))(
|
||||||
"restarts after worker exit while a descendant retains the worker stdio",
|
"restarts after worker exit while a descendant retains the worker stdio",
|
||||||
@@ -246,6 +298,9 @@ describe("supervisor durable logging", () => {
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
process.on("message", (message) => {
|
||||||
|
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||||
|
});
|
||||||
const marker = process.argv[1] + ".started";
|
const marker = process.argv[1] + ".started";
|
||||||
if (!existsSync(marker)) {
|
if (!existsSync(marker)) {
|
||||||
writeFileSync(marker, "started");
|
writeFileSync(marker, "started");
|
||||||
@@ -255,7 +310,6 @@ describe("supervisor durable logging", () => {
|
|||||||
{ detached: true, stdio: ["ignore", "inherit", "inherit"] },
|
{ detached: true, stdio: ["ignore", "inherit", "inherit"] },
|
||||||
);
|
);
|
||||||
descendant.unref();
|
descendant.unref();
|
||||||
process.on("SIGTERM", () => process.exit(0));
|
|
||||||
process.send?.({ type: "paseo:restart", reason: "stdio_descendant" });
|
process.send?.({ type: "paseo:restart", reason: "stdio_descendant" });
|
||||||
setInterval(() => {}, 1000);
|
setInterval(() => {}, 1000);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ interface SupervisorHeartbeatMessage {
|
|||||||
type: "paseo:supervisor-heartbeat";
|
type: "paseo:supervisor-heartbeat";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SupervisorGracefulShutdownMessage {
|
||||||
|
type: "paseo:graceful-shutdown";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SupervisorOptions {
|
interface SupervisorOptions {
|
||||||
name: string;
|
name: string;
|
||||||
startupMessage: string;
|
startupMessage: string;
|
||||||
@@ -192,11 +197,14 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
if (child !== currentChild) {
|
if (child !== currentChild) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeLifecycleLog("Worker did not exit after SIGTERM; forcing SIGKILL", {
|
writeLifecycleLog(
|
||||||
reason,
|
"Worker did not exit after graceful shutdown request; forcing process tree kill",
|
||||||
supervisorPid: process.pid,
|
{
|
||||||
workerPid: currentChild.pid ?? null,
|
reason,
|
||||||
});
|
supervisorPid: process.pid,
|
||||||
|
workerPid: currentChild.pid ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
void signalProcessTree(currentChild, "SIGKILL").catch((error) => {
|
void signalProcessTree(currentChild, "SIGKILL").catch((error) => {
|
||||||
writeLifecycleLog("Failed to force-kill worker process tree", {
|
writeLifecycleLog("Failed to force-kill worker process tree", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -327,17 +335,38 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const signalWorker = (signal: NodeJS.Signals, reason: string): void => {
|
const requestWorkerShutdown = (reason: string): void => {
|
||||||
if (!child) {
|
if (!child) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeLifecycleLog("Supervisor sending signal to worker", {
|
const currentChild = child;
|
||||||
|
const message: SupervisorGracefulShutdownMessage = {
|
||||||
|
type: "paseo:graceful-shutdown",
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
writeLifecycleLog("Supervisor requesting graceful worker shutdown", {
|
||||||
reason,
|
reason,
|
||||||
signal,
|
|
||||||
supervisorPid: process.pid,
|
supervisorPid: process.pid,
|
||||||
workerPid: child.pid ?? null,
|
workerPid: currentChild.pid ?? null,
|
||||||
|
});
|
||||||
|
if (!currentChild.connected) {
|
||||||
|
writeLifecycleLog("Graceful worker shutdown IPC unavailable", {
|
||||||
|
reason,
|
||||||
|
supervisorPid: process.pid,
|
||||||
|
workerPid: currentChild.pid ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentChild.send?.(message, (error) => {
|
||||||
|
if (error) {
|
||||||
|
writeLifecycleLog("Graceful worker shutdown IPC send failed", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
reason,
|
||||||
|
supervisorPid: process.pid,
|
||||||
|
workerPid: currentChild.pid ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
child.kill(signal);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestRestart = (reason: string) => {
|
const requestRestart = (reason: string) => {
|
||||||
@@ -347,7 +376,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
restarting = true;
|
restarting = true;
|
||||||
writeLifecycleLog("Restart requested", { reason });
|
writeLifecycleLog("Restart requested", { reason });
|
||||||
log(`${reason}. Stopping worker for restart...`);
|
log(`${reason}. Stopping worker for restart...`);
|
||||||
signalWorker("SIGTERM", reason);
|
requestWorkerShutdown(reason);
|
||||||
scheduleForceKill(reason);
|
scheduleForceKill(reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,7 +392,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
exitSupervisor(0);
|
exitSupervisor(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signalWorker("SIGTERM", reason);
|
requestWorkerShutdown(reason);
|
||||||
scheduleForceKill(reason);
|
scheduleForceKill(reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
|
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
"claude-opus-5",
|
"claude-opus-5",
|
||||||
|
"claude-fable-5-1",
|
||||||
"claude-fable-5",
|
"claude-fable-5",
|
||||||
"claude-fable-5[1m]",
|
"claude-fable-5[1m]",
|
||||||
"claude-opus-4-8[1m]",
|
"claude-opus-4-8[1m]",
|
||||||
@@ -495,6 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
|
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
|
||||||
|
expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode");
|
||||||
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
|
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
|
||||||
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
|
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
|
||||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||||
|
|||||||
@@ -45,12 +45,19 @@ export const CLAUDE_MODEL_MANIFEST = [
|
|||||||
supportsThinkingDisabled: true,
|
supportsThinkingDisabled: true,
|
||||||
supportsFastMode: true,
|
supportsFastMode: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "claude-fable-5-1",
|
||||||
|
label: "Fable 5.1",
|
||||||
|
description: "Fable 5.1 · Most powerful model",
|
||||||
|
contextWindowMaxTokens: 1_000_000,
|
||||||
|
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "claude-fable-5",
|
id: "claude-fable-5",
|
||||||
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
|
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
|
||||||
aliases: ["claude-fable-5[1m]"],
|
aliases: ["claude-fable-5[1m]"],
|
||||||
label: "Fable 5",
|
label: "Fable 5",
|
||||||
description: "Fable 5 · Most powerful model",
|
description: "Fable 5 · Previous release",
|
||||||
minimumClaudeCodeVersion: "2.1.169",
|
minimumClaudeCodeVersion: "2.1.169",
|
||||||
contextWindowMaxTokens: 1_000_000,
|
contextWindowMaxTokens: 1_000_000,
|
||||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||||
@@ -326,7 +333,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtimeMatch = trimmed.match(
|
const runtimeMatch = trimmed.match(
|
||||||
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||||
);
|
);
|
||||||
if (!runtimeMatch) {
|
if (!runtimeMatch) {
|
||||||
return null;
|
return null;
|
||||||
@@ -371,7 +378,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtimeMatch = trimmed.match(
|
const runtimeMatch = trimmed.match(
|
||||||
/claude[-_ ](opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||||
);
|
);
|
||||||
if (!runtimeMatch) {
|
if (!runtimeMatch) {
|
||||||
return null;
|
return null;
|
||||||
@@ -417,6 +424,14 @@ function normalizeMajorMinorClaudeModelId(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const family = familyValue.toLowerCase();
|
const family = familyValue.toLowerCase();
|
||||||
const suffix = hasOneMillionContext ? "[1m]" : "";
|
const suffix = hasOneMillionContext ? "[1m]" : "";
|
||||||
const candidate = `claude-${family}-${major}-${minor}${suffix}`;
|
const candidates = [
|
||||||
return isClaudeManifestModelId(candidate) ? candidate : null;
|
`claude-${family}-${major}-${minor}${suffix}`,
|
||||||
|
`claude-${family}-${major}-${minor}`,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (isClaudeManifestModelId(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe("getClaudeModels", () => {
|
|||||||
const models = getClaudeModels();
|
const models = getClaudeModels();
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
"claude-opus-5",
|
"claude-opus-5",
|
||||||
|
"claude-fable-5-1",
|
||||||
"claude-fable-5",
|
"claude-fable-5",
|
||||||
"claude-fable-5[1m]",
|
"claude-fable-5[1m]",
|
||||||
"claude-opus-4-8[1m]",
|
"claude-opus-4-8[1m]",
|
||||||
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
|
|||||||
expect(contextWindows).toEqual(
|
expect(contextWindows).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
["claude-opus-5", 1_000_000],
|
["claude-opus-5", 1_000_000],
|
||||||
|
["claude-fable-5-1", 1_000_000],
|
||||||
["claude-fable-5", 1_000_000],
|
["claude-fable-5", 1_000_000],
|
||||||
["claude-fable-5[1m]", 1_000_000],
|
["claude-fable-5[1m]", 1_000_000],
|
||||||
["claude-opus-4-8[1m]", 1_000_000],
|
["claude-opus-4-8[1m]", 1_000_000],
|
||||||
@@ -160,6 +162,9 @@ describe("getClaudeModels", () => {
|
|||||||
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
|
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
|
||||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
models.get("claude-fable-5-1")?.thinkingOptions?.map((option) => option.id),
|
||||||
|
).not.toContain(CLAUDE_DISABLED_THINKING_OPTION_ID);
|
||||||
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
|
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,6 +175,7 @@ describe("getClaudeModels", () => {
|
|||||||
["claude-sonnet-5[1m]", true, "high"],
|
["claude-sonnet-5[1m]", true, "high"],
|
||||||
["claude-sonnet-5-20260101", true, "high"],
|
["claude-sonnet-5-20260101", true, "high"],
|
||||||
["claude-fable-5", false, "high"],
|
["claude-fable-5", false, "high"],
|
||||||
|
["claude-fable-5-1", false, "high"],
|
||||||
["claude-haiku-4-5", false, undefined],
|
["claude-haiku-4-5", false, undefined],
|
||||||
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
||||||
[null, false, undefined],
|
[null, false, undefined],
|
||||||
@@ -458,7 +464,7 @@ describe("Claude Opus 5 catalog", () => {
|
|||||||
describe("Claude Fable 5 catalog", () => {
|
describe("Claude Fable 5 catalog", () => {
|
||||||
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
|
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
|
||||||
const fable5Models = getClaudeModels()
|
const fable5Models = getClaudeModels()
|
||||||
.filter((model) => model.id.startsWith("claude-fable-5"))
|
.filter((model) => model.id === "claude-fable-5" || model.id === "claude-fable-5[1m]")
|
||||||
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
|
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
|
||||||
id,
|
id,
|
||||||
aliases,
|
aliases,
|
||||||
@@ -491,6 +497,24 @@ describe("Claude Fable 5 catalog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Claude Fable 5.1 catalog", () => {
|
||||||
|
it("offers one Fable 5.1 entry with a 1M context window", () => {
|
||||||
|
const fable51Models = getClaudeModels()
|
||||||
|
.filter((model) => model.id.startsWith("claude-fable-5-1"))
|
||||||
|
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||||
|
|
||||||
|
expect(fable51Models).toEqual([
|
||||||
|
{ id: "claude-fable-5-1", label: "Fable 5.1", contextWindowMaxTokens: 1_000_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves suffixed and dated Fable 5.1 IDs to the catalog entry", () => {
|
||||||
|
expect(findClaudeModel("claude-fable-5-1[1m]")?.id).toBe("claude-fable-5-1");
|
||||||
|
expect(findClaudeModel("claude-fable-5-1-20260901")?.id).toBe("claude-fable-5-1");
|
||||||
|
expect(findClaudeModel("claude-fable-5-1-20260901[1m]")?.id).toBe("claude-fable-5-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("claudeManifestModelSupportsFastMode", () => {
|
describe("claudeManifestModelSupportsFastMode", () => {
|
||||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||||
@@ -502,5 +526,6 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
|||||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||||
|
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ type SupervisorLifecycleMessage =
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SupervisorHeartbeatMessage {
|
|
||||||
type: "paseo:supervisor-heartbeat";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BootstrapResult {
|
interface BootstrapResult {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
logger: ReturnType<typeof createRootLogger>;
|
logger: ReturnType<typeof createRootLogger>;
|
||||||
@@ -272,13 +268,19 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
process.on("message", (message: unknown) => {
|
process.on("message", (message: unknown) => {
|
||||||
if (
|
if (typeof message !== "object" || message === null || !("type" in message)) {
|
||||||
typeof message === "object" &&
|
return;
|
||||||
message !== null &&
|
}
|
||||||
"type" in message &&
|
const type = (message as { type?: unknown }).type;
|
||||||
(message as SupervisorHeartbeatMessage).type === "paseo:supervisor-heartbeat"
|
if (type === "paseo:supervisor-heartbeat") {
|
||||||
) {
|
|
||||||
lastSupervisorHeartbeatAt = Date.now();
|
lastSupervisorHeartbeatAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === "paseo:graceful-shutdown") {
|
||||||
|
const reason = (message as { reason?: unknown }).reason;
|
||||||
|
beginShutdown("Supervisor shutdown request", {
|
||||||
|
reason: typeof reason === "string" ? reason : "supervisor_requested_shutdown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
|
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user