mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 05:42:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9400a49af6 | ||
|
|
e9612df862 | ||
|
|
8dd4da6ee0 | ||
|
|
3998ea11d9 | ||
|
|
ba54575ab7 | ||
|
|
3e59adb4dc | ||
|
|
640b456915 | ||
|
|
dd5e8f95b1 | ||
|
|
59661f2bb8 | ||
|
|
06fae60809 | ||
|
|
95575ad05a | ||
|
|
7ea9286aa4 | ||
|
|
07125d2c02 | ||
|
|
0c38749c38 | ||
|
|
ecec33265e | ||
|
|
22463bb7e5 |
@@ -1,5 +1,34 @@
|
||||
# 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
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -404,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. `params.rpcTimeoutMs` overrides the 60-second OMP control-plane RPC deadline.
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
|
||||
+21
-10
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
its target and revision still match the canonical command.
|
||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
|
||||
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,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- 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;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
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
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
|
||||
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
|
||||
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.
|
||||
@@ -107,5 +118,5 @@ definition, no longer eligible to begin.
|
||||
## Tests
|
||||
|
||||
`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
|
||||
sequence there whenever ownership or ordering changes.
|
||||
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
|
||||
a sequence there whenever ownership or ordering changes.
|
||||
|
||||
+38
-15
@@ -2,8 +2,9 @@
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
Paseo does not sandbox it.
|
||||
subprocess and evaluates the client contribution in the app runtime.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -47,15 +48,13 @@ my-plugin/
|
||||
paseo-plugin.json
|
||||
index.ts
|
||||
main.client.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
||||
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
|
||||
runtime modules, so consumers do not install these packages when adding the plugin.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -84,28 +83,52 @@ directory always wins over shorthand resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo --path plugins/review
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin status
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
||||
it before changing the configured path. A compilation or startup failure restores the running
|
||||
version. Removing a Git source deletes Paseo's managed checkout.
|
||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
Git installation does not run a package manager or install script. A distributable source plugin
|
||||
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
||||
dependencies work only when the Paseo app already ships the native module.
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
that Paseo does not provide, generate source or assets, or perform another required preparation
|
||||
step:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`build` is an optional list of argv arrays. Each array must contain at least one non-empty string;
|
||||
shell command strings are rejected. Paseo starts the executable directly, without a shell, from the
|
||||
plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.
|
||||
|
||||
On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared
|
||||
commands, then validates, compiles, and activates the candidate. It logs each argv command and its
|
||||
output in the daemon log. If a command fails, the error includes its output, Paseo discards the
|
||||
candidate, and the existing installed and running version stays untouched. On a remote daemon, all
|
||||
of this happens on the remote daemon host.
|
||||
|
||||
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Git preparation commands are
|
||||
recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process
|
||||
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
||||
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||
log credentials or tokens.
|
||||
|
||||
+3
-3
@@ -67,7 +67,7 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi and OMP control-plane RPCs wait 60 seconds by default. Override the provider's `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs a different deadline. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results.
|
||||
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
@@ -85,9 +85,9 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
@@ -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-YiXz9rVJxzhtKdvYsg5UhVXTaetvH0sdJ/8dyfvSGQQ=
|
||||
sha256-j3lk7o4W10hcXeNIN3kGlqSHn8aCr+GCDkKYZNXNSrw=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36298,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37363,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/server": "0.7.0",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/server": "0.7.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37615,10 +37615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37629,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37872,7 +37872,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38797,13 +38797,13 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38812,15 +38812,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38833,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39051,17 +39051,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/highlight": "0.7.0",
|
||||
"@getpaseo/plugin": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/highlight": "0.7.2",
|
||||
"@getpaseo/plugin": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39985,7 +39985,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -39,7 +39,10 @@ import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -468,6 +471,10 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
switchAwayAndBackWithoutImageInstability,
|
||||
userPagesUntilAssistantImageRenders,
|
||||
} from "../support/helpers/assistant-images";
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
@@ -84,8 +85,15 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||
.toBe(Math.round(initialBox!.width));
|
||||
|
||||
await canvas.click({ position: { x: 4, y: 4 } });
|
||||
await workspace.client.sendAgentMessage(
|
||||
imageAgent.id,
|
||||
"Stay running while the assistant image lightbox is closed.",
|
||||
);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(lightboxImage).toHaveCount(0);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-message-render-limit-",
|
||||
title: "Assistant message render limit",
|
||||
initialPrompt: "Render the configured oversized response.",
|
||||
featureValues: { mockAssistantResponse: response },
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(assistantMessage).not.toContainText(hiddenTail);
|
||||
|
||||
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toHaveText(
|
||||
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
|
||||
);
|
||||
await expect(notice).toHaveCSS("font-style", "italic");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -37,6 +37,10 @@ function changesTree(page: Page) {
|
||||
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> {
|
||||
try {
|
||||
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 }) => {
|
||||
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 tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
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 }) => {
|
||||
@@ -302,7 +306,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
|
||||
await mode.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 seeCommitted.click();
|
||||
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 ({
|
||||
@@ -322,9 +326,8 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
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 second = page.getByTestId("diff-file-1-toggle");
|
||||
const normalBackground = await first.evaluate(
|
||||
(element) => getComputedStyle(element.parentElement!).backgroundColor,
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
||||
)
|
||||
.toBe("0px");
|
||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
const hoverBackground = await first.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(hoverBackground).not.toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect
|
||||
.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 expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect
|
||||
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||
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
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
||||
)
|
||||
.toBe(normalBackground);
|
||||
await expect
|
||||
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(hoverBackground);
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
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 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
|
||||
// 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
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
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(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 }) => {
|
||||
@@ -586,9 +658,10 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const renamedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-renamed.ts" });
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/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");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
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 openWorkspaceChanges(page, workspace);
|
||||
|
||||
const untrackedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-untracked.txt" });
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const untrackedHeader = diffHeaderForPath(panel, "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");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
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-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"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(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
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 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, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
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 expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
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/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
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 }) => {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1380,6 +1483,58 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
||||
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> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
@@ -1389,11 +1544,15 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
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.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
await openChangesPanel(page, timeout);
|
||||
}
|
||||
|
||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -1402,7 +1561,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
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,
|
||||
});
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
@@ -1701,6 +1860,30 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
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<{
|
||||
horizontalExtent: number;
|
||||
canvasPixels: string;
|
||||
|
||||
@@ -103,23 +103,15 @@ async function createFeatureCommit(repoPath: string): 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-header-content").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-name").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(content).not.toBeNull();
|
||||
expect(name).not.toBeNull();
|
||||
expect(stat).not.toBeNull();
|
||||
expect(canvas).not.toBeNull();
|
||||
expect(header!.height).toBeCloseTo(30, 0);
|
||||
expect(content!.x).toBeCloseTo(header!.x, 0);
|
||||
expect(content!.width).toBeCloseTo(header!.width, 0);
|
||||
expect(name!.x - header!.x).toBeCloseTo(12, 0);
|
||||
expect(name!.x + name!.width).toBeLessThan(stat!.x);
|
||||
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);
|
||||
expect(header!.x).toBeCloseTo(canvas!.x, 0);
|
||||
expect(header!.width).toBeCloseTo(canvas!.width, 0);
|
||||
expect(header!.y).toBeCloseTo(canvas!.y, 0);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
|
||||
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
||||
|
||||
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 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 CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
@@ -21,8 +23,10 @@ const POC_MEDIAN_FRAME_MS = Number(
|
||||
const POC_P95_FRAME_MS = Number(
|
||||
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_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
|
||||
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", () => {
|
||||
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);
|
||||
const repo = await createLargeDiffRepository();
|
||||
const client = await connectSeedClient();
|
||||
@@ -80,7 +208,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectSingleCanvasSurface(page);
|
||||
await expectBoundedCanvasSurfaces(page);
|
||||
cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||
await startChromiumTrace(cdp);
|
||||
@@ -110,6 +238,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
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(
|
||||
MAX_MEDIAN_FRAME_MS,
|
||||
);
|
||||
@@ -122,9 +253,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
||||
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
||||
);
|
||||
expect(renderer.addedElements).toBe(0);
|
||||
expect(renderer.removedElements).toBe(0);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||
|
||||
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
||||
@@ -169,7 +300,7 @@ async function measureAdditionalPerformanceScenario(
|
||||
testInfo: TestInfo,
|
||||
name: "wrapped" | "split",
|
||||
) {
|
||||
await expectSingleCanvasSurface(page);
|
||||
await expectBoundedCanvasSurfaces(page);
|
||||
await startRendererInstrumentation(page);
|
||||
const scrollReports = [];
|
||||
const heapUsage = [];
|
||||
@@ -202,9 +333,9 @@ async function measureAdditionalPerformanceScenario(
|
||||
}
|
||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
expect(renderer.addedElements).toBe(0);
|
||||
expect(renderer.removedElements).toBe(0);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||
return {
|
||||
scrollReports,
|
||||
heapUsage,
|
||||
@@ -251,6 +382,20 @@ async function createLargeDiffRepository() {
|
||||
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> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
@@ -289,8 +434,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||
return performance.now() - startedAt;
|
||||
}
|
||||
|
||||
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(2);
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
const elementCount = await page
|
||||
.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 {
|
||||
scrollTop: number;
|
||||
image: Buffer;
|
||||
@@ -454,23 +685,35 @@ async function expectContinuousFramesMatchStableRender(
|
||||
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
||||
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
||||
const firstOffset = await setHorizontalOffset(first, 240);
|
||||
const secondOffset = await setHorizontalOffset(second, 120);
|
||||
await setVerticalOffset(scroller, 0);
|
||||
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);
|
||||
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
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 setVerticalOffset(scroller, 0);
|
||||
await expect(first).toBeAttached();
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return locator.evaluate(async (element, nextOffset) => {
|
||||
element.scrollLeft = nextOffset;
|
||||
|
||||
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||
|
||||
// Workspace B (same directory): the SAME directory-backed content is
|
||||
// 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 openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
expectTurnCopyButton,
|
||||
} from "../support/helpers/agent-stream";
|
||||
import {
|
||||
expectReconnectingToastDebounced,
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
@@ -181,13 +180,17 @@ test.describe("Viewed agent timelines", () => {
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await expectReconnectingToastDebounced(page);
|
||||
await expectReconnectingToastVisible(page);
|
||||
await gate.waitForBlockedConnection();
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
// Hold the first authoritative catch-up response so the assertion observes
|
||||
// the reconnect boundary instead of racing a socket that has not reopened yet.
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await expectReconnectingToastGone(page);
|
||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||
exact: true,
|
||||
@@ -209,6 +212,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
createIdleAgent,
|
||||
createMockIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
@@ -180,6 +180,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await daemonGate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
@@ -217,12 +218,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
createIdleAgent(primaryWorkspace.client, {
|
||||
createMockIdleAgent(primaryWorkspace.client, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title: "Active host agent",
|
||||
}),
|
||||
createIdleAgent(secondaryWorkspace.client, {
|
||||
createMockIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: "Inactive host agent",
|
||||
@@ -319,12 +320,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
@@ -14,6 +14,31 @@ function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
interface PositionedToolCall {
|
||||
locator: Locator;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
|
||||
const entries = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
locator: toolCalls.nth(index),
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
return entries.filter(
|
||||
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
|
||||
return toolCalls.toSorted(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
|
||||
)[0];
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
@@ -180,58 +205,53 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
const initialToolCalls = await readRenderedToolCalls(toolCalls);
|
||||
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
|
||||
expect(
|
||||
candidate,
|
||||
initialCandidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds,
|
||||
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
if (!currentButtonBounds) return false;
|
||||
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
|
||||
const closest = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
);
|
||||
if (!closest) return false;
|
||||
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
|
||||
if (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
);
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
|
||||
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
|
||||
(scroll as HTMLElement).scrollTop += delta;
|
||||
}, deltaY);
|
||||
return false;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
|
||||
const alignedToolCall = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
)?.locator;
|
||||
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
alignedToolCall!.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
@@ -245,7 +265,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
|
||||
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
async function seedIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
provider: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
...provider,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
@@ -84,6 +84,32 @@ export async function createIdleAgent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMockIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||
agentId: string,
|
||||
|
||||
@@ -315,6 +315,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const suppressedAgentStreamItemTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
let blockedConnectionCount = 0;
|
||||
let blockedConnectionCountAtDrop = 0;
|
||||
const blockedConnectionWaiters = new Set<() => void>();
|
||||
let latestServer: WebSocketRoute | null = null;
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
@@ -443,6 +446,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
blockedConnectionCount += 1;
|
||||
for (const resolve of blockedConnectionWaiters) resolve();
|
||||
blockedConnectionWaiters.clear();
|
||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||
return;
|
||||
}
|
||||
@@ -583,6 +589,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
forward?.();
|
||||
},
|
||||
async drop(): Promise<void> {
|
||||
blockedConnectionCountAtDrop = blockedConnectionCount;
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
@@ -592,6 +599,10 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
),
|
||||
);
|
||||
},
|
||||
async waitForBlockedConnection(): Promise<void> {
|
||||
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
|
||||
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
const changedFile = page
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await expect(changedFile).toBeVisible({ timeout });
|
||||
await changedFile.click();
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,14 +112,6 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastDebounced(
|
||||
page: Page,
|
||||
options?: { durationMs?: number },
|
||||
): Promise<void> {
|
||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -162,5 +162,10 @@
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.105.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,
|
||||
{
|
||||
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||
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> {
|
||||
const timing = {
|
||||
startedAt: timestamp(1),
|
||||
completedAt: timestamp(9),
|
||||
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", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface TurnFooterHost {
|
||||
|
||||
export interface StreamLayoutItem {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
gapBelow: number;
|
||||
@@ -232,60 +230,109 @@ function getSegmentNeighbor(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
return input.items.map((item, index) => {
|
||||
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),
|
||||
});
|
||||
// Last layout emitted for each stream item. A row only rerenders when its layout item identity
|
||||
// changes, so an item whose render-relevant layout is unchanged must keep its previous object even
|
||||
// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
|
||||
const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
|
||||
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
items: input.items,
|
||||
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,
|
||||
};
|
||||
function areTurnFooterHostsEqual(
|
||||
left: TurnFooterHost | null,
|
||||
right: TurnFooterHost | null,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
return (
|
||||
left.itemId === right.itemId &&
|
||||
left.timing === right.timing &&
|
||||
left.startIndex === right.startIndex &&
|
||||
left.items === right.items
|
||||
);
|
||||
}
|
||||
|
||||
function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
|
||||
return (
|
||||
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.get("visible-a")).toEqual({
|
||||
startedAt: tail[2]?.timestamp,
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
@@ -176,7 +175,6 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -196,7 +194,6 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
@@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
};
|
||||
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 {
|
||||
return item.id;
|
||||
}
|
||||
@@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||
// Keep unchanged item identities intact so live updates only rerender rows
|
||||
// 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 historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
const hasMeasuredViewport =
|
||||
|
||||
@@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => {
|
||||
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", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
} from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
@@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
segments,
|
||||
segments: inputSegments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision,
|
||||
boundary,
|
||||
renderers,
|
||||
@@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = 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 isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -86,7 +86,13 @@ describe("canonical turn membership", () => {
|
||||
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([
|
||||
@@ -118,7 +124,6 @@ describe("canonical turn membership", () => {
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
startedAt: at(1),
|
||||
completedAt: at(9),
|
||||
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: {
|
||||
item: StreamItem;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
@@ -229,7 +245,13 @@ function renderHistoryStreamItem(input: {
|
||||
if (!layoutItem) {
|
||||
return null;
|
||||
}
|
||||
return input.renderStreamItem(layoutItem);
|
||||
return (
|
||||
<HistoryStreamRow
|
||||
item={input.item}
|
||||
layoutItem={layoutItem}
|
||||
renderStreamItem={input.renderStreamItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLiveHeadStreamItem(input: {
|
||||
@@ -789,9 +811,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[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(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const group = projectedToolCalls.groupsByHostId.get(item.id);
|
||||
const group = getToolCallGroup(item.id);
|
||||
if (!group) {
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
@@ -818,8 +845,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
},
|
||||
[
|
||||
projectedToolCalls.groupsByHostId,
|
||||
expandedToolCallGroupIds,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
],
|
||||
|
||||
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
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 {
|
||||
KeyboardActionDispatcherProvider,
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
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 { getNextThemePreference } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -627,10 +627,9 @@ function SidebarChrome({
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
const active = visible && isOpen;
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||
return (
|
||||
<SidebarModelProvider active={active}>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT,
|
||||
capAssistantMessageForRender,
|
||||
getUtf8ByteLength,
|
||||
} from "./assistant-message-render-limit";
|
||||
|
||||
describe("assistant message render limit", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("leaves messages at the limit unchanged", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
|
||||
});
|
||||
|
||||
it("caps oversized messages before markdown rendering", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not split a grapheme cluster at the boundary", () => {
|
||||
const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - 1);
|
||||
|
||||
expect(capAssistantMessageForRender(`${prefix}étail`)).toEqual({
|
||||
text: prefix,
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts the complete message size in UTF-8 bytes", () => {
|
||||
expect(getUtf8ByteLength("aé😀")).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined });
|
||||
const { capAssistantMessageForRender: capWithoutSegmenter } =
|
||||
await import("./assistant-message-render-limit");
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capWithoutSegmenter(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { clampToSafeRevealBoundary, isTextRevealPacingSupported } from "@/agent-stream/text-reveal";
|
||||
|
||||
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
|
||||
|
||||
export interface CappedAssistantMessage {
|
||||
text: string;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
export function getUtf8ByteLength(message: string): number {
|
||||
let bytes = 0;
|
||||
|
||||
for (let index = 0; index < message.length; index += 1) {
|
||||
const codeUnit = message.charCodeAt(index);
|
||||
if (codeUnit < 0x80) {
|
||||
bytes += 1;
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2;
|
||||
} else if (
|
||||
codeUnit >= 0xd800 &&
|
||||
codeUnit <= 0xdbff &&
|
||||
index + 1 < message.length &&
|
||||
message.charCodeAt(index + 1) >= 0xdc00 &&
|
||||
message.charCodeAt(index + 1) <= 0xdfff
|
||||
) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
|
||||
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
|
||||
return { text: message, capped: false };
|
||||
}
|
||||
|
||||
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
|
||||
if (isTextRevealPacingSupported()) {
|
||||
end = clampToSafeRevealBoundary(message, end);
|
||||
} else {
|
||||
const finalCodeUnit = message.charCodeAt(end - 1);
|
||||
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: message.slice(0, end), capped: true };
|
||||
}
|
||||
@@ -136,6 +136,8 @@ let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
dom.window.requestAnimationFrame = vi.fn(() => 1);
|
||||
dom.window.cancelAnimationFrame = vi.fn();
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -33,23 +34,27 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !source) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
}, [onClose, source]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
return true;
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && source !== null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
@@ -87,7 +92,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
NativeExplorerSidebarDock,
|
||||
} from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
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 type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
@@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel(
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
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 client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
@@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel(
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerOpen) {
|
||||
if (enabled && isExplorerActive) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerOpen) {
|
||||
if (!isExplorerActive) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -81,7 +82,7 @@ export function CompactExplorerSidebar({
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const isActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
@@ -99,11 +100,11 @@ export function CompactExplorerSidebar({
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen,
|
||||
isOpen: isActive,
|
||||
});
|
||||
showMobileAgent();
|
||||
},
|
||||
[isOpen, showMobileAgent],
|
||||
[isActive, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
@@ -127,7 +128,7 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<RetainedPanelActivity active={isActive}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isOpen}
|
||||
isOpen={isActive}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatDiffCount } from "@/git/file-header-presentation";
|
||||
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
@@ -7,15 +8,6 @@ interface DiffStatProps {
|
||||
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) {
|
||||
return (
|
||||
<View style={styles.row} testID={testID}>
|
||||
|
||||
@@ -62,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
@@ -121,6 +120,7 @@ interface SidebarLabels {
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
active: boolean;
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
closeSidebar: () => void;
|
||||
@@ -273,6 +273,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
@@ -604,6 +605,7 @@ function SidebarFooter({
|
||||
}
|
||||
|
||||
function MobileSidebar({
|
||||
active,
|
||||
theme,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
@@ -638,7 +640,6 @@ function MobileSidebar({
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
closeSidebar();
|
||||
@@ -737,7 +738,7 @@ function MobileSidebar({
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
} from "@/assistant-selection-copy/markup";
|
||||
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
@@ -577,7 +578,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
durationMs?: number | null;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -615,9 +616,8 @@ const TIMESTAMP_REVEAL_MS = 3000;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* or tap (native). The hidden sizer keeps the label width stable while the
|
||||
* visible text swaps.
|
||||
* Shows the turn duration and swaps to the end timestamp when both are known.
|
||||
* A turn without a visible start shows its end timestamp directly.
|
||||
*/
|
||||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
@@ -639,7 +639,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
}, []);
|
||||
|
||||
const durationLabel = useMemo(
|
||||
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
|
||||
() =>
|
||||
durationMs !== undefined && durationMs !== null
|
||||
? `Worked for ${formatDuration(durationMs)}`
|
||||
: "",
|
||||
[durationMs],
|
||||
);
|
||||
const timestampLabel = useMemo(
|
||||
@@ -647,7 +650,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
[completedAt],
|
||||
);
|
||||
|
||||
const canSwap = Boolean(timestampLabel);
|
||||
const primaryLabel = durationLabel || timestampLabel;
|
||||
const canSwap = Boolean(durationLabel && timestampLabel);
|
||||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||||
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
@@ -678,22 +682,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{durationLabel ? (
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||||
>
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
container width is stable across hover transitions. */}
|
||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
|
||||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||||
</Text>
|
||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||
{showTimestamp ? timestampLabel : durationLabel}
|
||||
{showTimestamp ? timestampLabel : primaryLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -763,6 +767,13 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerCompactBottom: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
cappedNotice: {
|
||||
marginTop: theme.spacing[3],
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontStyle: "italic",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
imageFrame: {
|
||||
width: "100%",
|
||||
minHeight: 160,
|
||||
@@ -1488,10 +1499,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
spacing = "default",
|
||||
phase,
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
|
||||
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
||||
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
||||
const revealedMessage = useRevealedText(message, phase);
|
||||
const revealedMessage = useRevealedText(renderedMessage.text, phase);
|
||||
const fullMessageByteLength = useMemo(
|
||||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||||
[message, phase, renderedMessage.capped],
|
||||
);
|
||||
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
@@ -1970,6 +1987,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
/>
|
||||
</AssistantMessageBlockContainer>
|
||||
))}
|
||||
{fullMessageByteLength !== null ? (
|
||||
<Text
|
||||
testID="assistant-message-capped-notice"
|
||||
style={assistantMessageStylesheet.cappedNotice}
|
||||
>
|
||||
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectHeaderRowProps {
|
||||
@@ -1540,7 +1540,7 @@ function ProjectBlock({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
dragGestureHostPresented,
|
||||
dragGestureHostActive,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostBadgeByServerId,
|
||||
@@ -1565,7 +1565,7 @@ function ProjectBlock({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
useNestable: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -1745,7 +1745,7 @@ function ProjectBlock({
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canToggleWorkspaces ? (
|
||||
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
areProjectBlockSelectionsEqual(previous, next)
|
||||
);
|
||||
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
dragGestureHostActive,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
pathname={pathname}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
dragGestureHostActive,
|
||||
}: {
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
const pinnedWorkspaces = useMemo(
|
||||
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2072,7 +2072,7 @@ function ProjectModeList({
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
dragGestureHostActive,
|
||||
pathname,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2289,7 +2289,7 @@ function ProjectModeList({
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
@@ -2311,7 +2311,7 @@ function ProjectModeList({
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
dragGestureHostActive,
|
||||
projectIconByProjectViewKey,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2388,7 +2388,7 @@ function ProjectModeList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
);
|
||||
@@ -2411,7 +2411,7 @@ function ProjectModeList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canTogglePinnedChats ? (
|
||||
|
||||
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
|
||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||
sidebarFilterEmpty?: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
/>
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
|
||||
@@ -131,6 +131,46 @@ describe("EditingTextInputNative", () => {
|
||||
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", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
|
||||
@@ -35,31 +35,54 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
const inputRef = useRef<NativeInput | null>(null);
|
||||
const initialTextRef = useRef(initialValue);
|
||||
const textRef = useRef(initialTextRef.current);
|
||||
const restoreFocusAfterResetRef = useRef(false);
|
||||
const [nativeInputRevision, setNativeInputRevision] = useState(0);
|
||||
// Clearing swaps the native input for a fresh instance (see replaceText).
|
||||
// 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) => {
|
||||
inputRef.current = input;
|
||||
if (!input || !restoreFocusAfterResetRef.current) return;
|
||||
restoreFocusAfterResetRef.current = false;
|
||||
input.focus();
|
||||
if (input) isAwaitingReplacementRef.current = false;
|
||||
}, []);
|
||||
|
||||
const setReplacementFocus = useCallback((autoFocus: boolean) => {
|
||||
setReplacement((current) => ({ ...current, autoFocus }));
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
blur: () => inputRef.current?.blur(),
|
||||
focus: () => {
|
||||
if (isAwaitingReplacementRef.current) {
|
||||
setReplacementFocus(true);
|
||||
return;
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
blur: () => {
|
||||
if (isAwaitingReplacementRef.current) {
|
||||
setReplacementFocus(false);
|
||||
return;
|
||||
}
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
if (nextText === "") {
|
||||
restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false;
|
||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText(nextText, selection);
|
||||
} else {
|
||||
inputRef.current?.clear?.();
|
||||
}
|
||||
setNativeInputRevision((revision) => revision + 1);
|
||||
isAwaitingReplacementRef.current = true;
|
||||
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
||||
return;
|
||||
}
|
||||
if (inputRef.current?.replaceText) {
|
||||
@@ -93,11 +116,14 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
[onPasteError, onPasteImages],
|
||||
);
|
||||
|
||||
const autoFocus = replacement.revision === 0 ? props.autoFocus : replacement.autoFocus;
|
||||
|
||||
if (onPasteImages || onPasteError) {
|
||||
return (
|
||||
<PasteInput
|
||||
{...props}
|
||||
key={nativeInputRevision}
|
||||
autoFocus={autoFocus}
|
||||
key={replacement.revision}
|
||||
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
|
||||
defaultValue={textRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
@@ -109,7 +135,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
return (
|
||||
<BottomSheetTextInput
|
||||
{...props}
|
||||
key={nativeInputRevision}
|
||||
autoFocus={autoFocus}
|
||||
key={replacement.revision}
|
||||
ref={assignInputRef as unknown as React.Ref<never>}
|
||||
defaultValue={textRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
@@ -119,7 +146,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
key={nativeInputRevision}
|
||||
autoFocus={autoFocus}
|
||||
key={replacement.revision}
|
||||
ref={assignInputRef as React.Ref<TextInput>}
|
||||
defaultValue={textRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface DocumentFileHeaderProps {
|
||||
mode: DiffDocumentProps["mode"];
|
||||
onToggleFile: (path: string) => void;
|
||||
onSelectPath: (path: string) => void;
|
||||
canvasRendered?: boolean;
|
||||
onActiveChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
@@ -17,6 +19,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
mode,
|
||||
onToggleFile,
|
||||
onSelectPath,
|
||||
canvasRendered = false,
|
||||
onActiveChange,
|
||||
}: DocumentFileHeaderProps) {
|
||||
const activate = useCallback(
|
||||
(path: string) => {
|
||||
@@ -47,6 +51,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
onDuplicate={working?.onDuplicate}
|
||||
onRevert={working?.onRevert}
|
||||
testID={`diff-file-${file.fileIndex}`}
|
||||
canvasRendered={canvasRendered}
|
||||
onActiveChange={onActiveChange}
|
||||
/>
|
||||
);
|
||||
}, documentFileHeaderPropsEqual);
|
||||
@@ -84,6 +90,8 @@ function documentFileHeaderIdentityMatches(
|
||||
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
|
||||
previous.onToggleFile !== next.onToggleFile ||
|
||||
previous.onSelectPath !== next.onSelectPath ||
|
||||
previous.canvasRendered !== next.canvasRendered ||
|
||||
previous.onActiveChange !== next.onActiveChange ||
|
||||
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",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
|
||||
@@ -3,12 +3,13 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { createDiffPalette, retainDiffPalette } from "./palette";
|
||||
import { DiffSurface } from "./surface";
|
||||
import type { DiffDocumentProps, DiffPalette } from "./types";
|
||||
import type { DiffDocumentProps, DiffHeaderTypography, DiffPalette } from "./types";
|
||||
|
||||
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
|
||||
|
||||
type ThemedDiffDocumentProps = DiffDocumentProps & {
|
||||
palette: DiffPalette;
|
||||
headerTypography: DiffHeaderTypography;
|
||||
};
|
||||
|
||||
const EMPTY_PATHS: string[] = [];
|
||||
@@ -45,6 +46,11 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
||||
|
||||
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
||||
palette: createDiffPalette(theme),
|
||||
headerTypography: {
|
||||
family: theme.fontFamily.ui,
|
||||
size: theme.fontSize.base,
|
||||
statSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
export function DiffDocument(props: DiffDocumentProps) {
|
||||
|
||||
@@ -78,6 +78,11 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number })
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
|
||||
@@ -58,6 +58,11 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
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 - 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 {
|
||||
return {
|
||||
measure,
|
||||
|
||||
@@ -37,9 +37,33 @@ interface CellSource {
|
||||
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 {
|
||||
const rows: DiffRow[] = [];
|
||||
const files: DiffFileSection[] = [];
|
||||
const reusableModels = indexReusableModels(input.reuseFrom);
|
||||
const currentReviewGeometryKey = reviewGeometryKey(input.reviewActions);
|
||||
let documentTop = 0;
|
||||
|
||||
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);
|
||||
let maximumHorizontalOverflow = 0;
|
||||
|
||||
const reusableModel = input.reuseFrom?.find((candidate) =>
|
||||
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed),
|
||||
);
|
||||
const reusableFile = reusableModel?.files.find(
|
||||
(candidate) => candidate.file === file && !candidate.isCollapsed,
|
||||
);
|
||||
const reusable = findReusableFileRows({
|
||||
indexes: reusableModels,
|
||||
buildInput: input,
|
||||
sourceFile: file,
|
||||
fileTop,
|
||||
bodyTop,
|
||||
});
|
||||
const reusableModel = reusable?.model;
|
||||
const reusableFile = reusable?.file;
|
||||
|
||||
if (!isCollapsed && reusableFile) {
|
||||
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
|
||||
for (const reusedRow of reusedRows) {
|
||||
if (reusedRow.kind === "line") {
|
||||
const reviewHeight = reviewHeightForCells(reusedRow.cells, input);
|
||||
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,
|
||||
if (!isCollapsed) {
|
||||
const result =
|
||||
reusableModel && reusableFile
|
||||
? appendReusableFileRows({
|
||||
rows,
|
||||
input,
|
||||
model: reusableModel,
|
||||
file: reusableFile,
|
||||
fileIndex,
|
||||
fileTop,
|
||||
bodyTop,
|
||||
reviewGeometryKey: currentReviewGeometryKey,
|
||||
})
|
||||
: appendNewFileRows({
|
||||
rows,
|
||||
input,
|
||||
file,
|
||||
fileIndex,
|
||||
fileTop,
|
||||
bodyTop,
|
||||
gutterWidth,
|
||||
});
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
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;
|
||||
documentTop = result.bottom;
|
||||
maximumHorizontalOverflow = result.maximumHorizontalOverflow;
|
||||
}
|
||||
|
||||
files.push({
|
||||
@@ -170,9 +138,193 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
||||
layout: input.layout,
|
||||
wrapLines: input.wrapLines,
|
||||
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 {
|
||||
"worklet";
|
||||
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
|
||||
@@ -183,7 +335,7 @@ export function retainReusableModels(
|
||||
next: DiffDocumentModel,
|
||||
): DiffDocumentModel[] {
|
||||
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];
|
||||
}
|
||||
@@ -191,13 +343,14 @@ export function retainReusableModels(
|
||||
function lineSources(
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
layout: "unified" | "split",
|
||||
includeTokens: boolean,
|
||||
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
|
||||
if (layout === "split") {
|
||||
return buildSplitDiffRows(file).map((row) => {
|
||||
if (row.kind === "header") {
|
||||
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) => [
|
||||
@@ -206,7 +359,7 @@ function lineSources(
|
||||
content: entry.line.content,
|
||||
lineNumber: entry.lineNumber,
|
||||
reviewTarget: entry.reviewTarget,
|
||||
tokenText: compactHighlightTokens(entry.line.tokens ?? []),
|
||||
tokenText: includeTokens ? compactHighlightTokens(entry.line.tokens ?? []) : [],
|
||||
sourceIdentity: {
|
||||
hunkIndex: entry.hunkIndex,
|
||||
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;
|
||||
return {
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
lineNumber: line.lineNumber,
|
||||
reviewTarget: line.reviewTarget,
|
||||
tokenText: compactHighlightTokens(line.tokens ?? []),
|
||||
tokenText: includeTokens ? compactHighlightTokens(line.tokens ?? []) : [],
|
||||
sourceIdentity: {
|
||||
hunkIndex: line.hunkIndex,
|
||||
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: {
|
||||
source: CellSource;
|
||||
availableWidth: number;
|
||||
|
||||
@@ -107,6 +107,11 @@ function build(layout: "unified" | "split") {
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
nativeCanvasSlabsForViewport,
|
||||
nativeCanvasWindowBucket,
|
||||
nativeCanvasWindowTop,
|
||||
nativeStickyHeaderIndices,
|
||||
} from "./native-slabs";
|
||||
import type { DiffDocumentModel } from "./types";
|
||||
|
||||
@@ -34,15 +33,11 @@ function modelWithBodies(
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
viewportWidth: 400,
|
||||
reviewGeometryKey: "",
|
||||
};
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const slabs = buildNativeCanvasSlabs(
|
||||
modelWithBodies([
|
||||
@@ -58,9 +53,11 @@ describe("native canvas slabs", () => {
|
||||
|
||||
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
|
||||
{ path: "small.ts", top: 30, height: 200 },
|
||||
{ path: "large.ts", top: 260, height: 1200 },
|
||||
{ path: "large.ts", top: 1460, height: 1200 },
|
||||
{ path: "large.ts", top: 2660, height: 800 },
|
||||
{ path: "large.ts", top: 260, height: 768 },
|
||||
{ path: "large.ts", top: 1028, height: 768 },
|
||||
{ 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([
|
||||
30, 1230, 2430,
|
||||
798, 1566, 2334,
|
||||
]);
|
||||
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
|
||||
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
|
||||
|
||||
@@ -8,15 +8,11 @@ export interface NativeCanvasSlab {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function nativeStickyHeaderIndices(fileCount: number): number[] {
|
||||
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
|
||||
}
|
||||
|
||||
export function buildNativeCanvasSlabs(
|
||||
model: DiffDocumentModel,
|
||||
viewportHeight: number,
|
||||
): NativeCanvasSlab[] {
|
||||
const slabHeight = Math.max(768, viewportHeight * 2);
|
||||
const slabHeight = Math.max(768, viewportHeight);
|
||||
return model.files.flatMap((file) => {
|
||||
const slabs: NativeCanvasSlab[] = [];
|
||||
let slabIndex = 0;
|
||||
@@ -39,8 +35,8 @@ export function nativeCanvasSlabsForViewport(
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
): NativeCanvasSlab[] {
|
||||
const top = Math.max(0, scrollTop - viewportHeight);
|
||||
const bottom = scrollTop + viewportHeight * 2;
|
||||
const top = Math.max(0, scrollTop - viewportHeight / 2);
|
||||
const bottom = scrollTop + viewportHeight * 1.5;
|
||||
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import {
|
||||
ClipOp,
|
||||
PaintStyle,
|
||||
Skia,
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
type SkCanvas,
|
||||
type SkPaint,
|
||||
type SkPicture,
|
||||
} 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 { nativeTextRuns } from "./native-text-runs";
|
||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||
import { codeLineNumberTone } from "./palette";
|
||||
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 {
|
||||
DiffCell,
|
||||
DiffDocumentModel,
|
||||
@@ -31,6 +52,10 @@ export interface NativePaints {
|
||||
additionBackground: SkPaint;
|
||||
deletionBackground: SkPaint;
|
||||
headerSurface: SkPaint;
|
||||
headerBorder: SkPaint;
|
||||
statusSuccess: SkPaint;
|
||||
statusDanger: SkPaint;
|
||||
statusWarning: SkPaint;
|
||||
emptyBackground: SkPaint;
|
||||
text: Record<string, SkPaint>;
|
||||
}
|
||||
@@ -46,6 +71,10 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
|
||||
additionBackground: paint(palette.additionBackground),
|
||||
deletionBackground: paint(palette.deletionBackground),
|
||||
headerSurface: paint(palette.headerSurface),
|
||||
headerBorder: paint(palette.headerBorder),
|
||||
statusSuccess: paint(palette.statusSuccess),
|
||||
statusDanger: paint(palette.statusDanger),
|
||||
statusWarning: paint(palette.statusWarning),
|
||||
emptyBackground: paint(palette.emptyBackground),
|
||||
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 {
|
||||
const result = Skia.Paint();
|
||||
result.setColor(Skia.Color(color));
|
||||
|
||||
@@ -1,8 +1,94 @@
|
||||
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";
|
||||
|
||||
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", () => {
|
||||
const paintedText: string[] = [];
|
||||
const context = {
|
||||
@@ -314,6 +400,7 @@ function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
|
||||
layout,
|
||||
wrapLines: false,
|
||||
viewportWidth: 200,
|
||||
reviewGeometryKey: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +443,11 @@ const palette: DiffPalette = {
|
||||
deletionBackground: "red-bg",
|
||||
emptyBackground: "empty",
|
||||
selection: "selection",
|
||||
headerActiveSurface: "active-header",
|
||||
headerBorder: "header-border",
|
||||
statusSuccess: "success",
|
||||
statusDanger: "danger",
|
||||
statusWarning: "warning",
|
||||
syntax: { first: "red", second: "blue" },
|
||||
};
|
||||
|
||||
@@ -427,6 +519,7 @@ const model: DiffDocumentModel = {
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
viewportWidth: 200,
|
||||
reviewGeometryKey: "",
|
||||
};
|
||||
|
||||
const modelWithReview: DiffDocumentModel = {
|
||||
|
||||
@@ -6,10 +6,26 @@ import {
|
||||
visibleRowRange,
|
||||
} from "./model";
|
||||
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 type {
|
||||
DiffCell,
|
||||
DiffDocumentModel,
|
||||
DiffFileSection,
|
||||
DiffHeaderTypography,
|
||||
DiffLineRow,
|
||||
DiffPalette,
|
||||
DiffSelection,
|
||||
@@ -86,6 +102,167 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
|
||||
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(
|
||||
input: PaintWebViewportInput & {
|
||||
row: DiffLineRow;
|
||||
|
||||
@@ -59,6 +59,11 @@ const palette: DiffPalette = {
|
||||
deletionBackground: "red-bg",
|
||||
emptyBackground: "empty",
|
||||
selection: "selection",
|
||||
headerActiveSurface: "active-header",
|
||||
headerBorder: "header-border",
|
||||
statusSuccess: "success",
|
||||
statusDanger: "danger",
|
||||
statusWarning: "warning",
|
||||
syntax: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ export function createDiffPalette(theme: Theme): DiffPalette {
|
||||
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
|
||||
emptyBackground: theme.colors.surface0,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -32,6 +37,11 @@ export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): Dif
|
||||
previous.deletionBackground !== next.deletionBackground ||
|
||||
previous.emptyBackground !== next.emptyBackground ||
|
||||
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)
|
||||
) {
|
||||
return next;
|
||||
|
||||
@@ -23,6 +23,11 @@ import { InlineReviewThread } from "@/review";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import {
|
||||
diffInteractionWindowTop,
|
||||
diffMaterializationWindow,
|
||||
resolveVisibleFileSections,
|
||||
} from "./header-layout";
|
||||
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
||||
import { retainDiffViewport } from "./viewport";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.native";
|
||||
@@ -42,16 +47,22 @@ import {
|
||||
nativeCanvasSlabsForViewport,
|
||||
nativeCanvasWindowBucket,
|
||||
nativeCanvasWindowTop,
|
||||
nativeStickyHeaderIndices,
|
||||
type NativeCanvasSlab,
|
||||
} from "./native-slabs";
|
||||
import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native";
|
||||
import {
|
||||
createNativePaints,
|
||||
recordNativeHeaderPicture,
|
||||
recordNativeSlabPictures,
|
||||
type NativePaints,
|
||||
} from "./paint.native";
|
||||
import {
|
||||
createNativeTextLayoutStore,
|
||||
createNativeHeaderTextLayout,
|
||||
createNativeTextMeasurer,
|
||||
disposeNativeTextLayout,
|
||||
prepareNativeTextLayout,
|
||||
type NativeTextLayout,
|
||||
type NativeHeaderTextLayout,
|
||||
} from "./text.native";
|
||||
import type {
|
||||
DiffDocumentModel,
|
||||
@@ -68,6 +79,7 @@ const CODE_LEFT_PADDING = 8;
|
||||
export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [fileWindowTop, setFileWindowTop] = useState(0);
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
const reusableModelRef = useRef<{
|
||||
@@ -126,6 +138,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
@@ -135,6 +148,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
return next;
|
||||
}, [
|
||||
measurement,
|
||||
fileWindowTop,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
@@ -144,6 +158,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
t,
|
||||
typography,
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
]);
|
||||
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
|
||||
const textLayoutStore = useMemo(
|
||||
@@ -161,6 +176,38 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[model, 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) => {
|
||||
scrollTop.value = event.contentOffset.y;
|
||||
@@ -206,12 +253,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}
|
||||
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
|
||||
const contentStyle = useMemo(
|
||||
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
|
||||
[viewport.height],
|
||||
);
|
||||
const stickyHeaderIndices = useMemo(
|
||||
() => nativeStickyHeaderIndices(model.files.length),
|
||||
[model.files.length],
|
||||
() => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
|
||||
[model.height, viewport.height],
|
||||
);
|
||||
return (
|
||||
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
||||
@@ -222,7 +265,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
testID="git-diff-scroll"
|
||||
>
|
||||
<NativeCanvasSlabLayer
|
||||
@@ -233,23 +275,31 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
paints={paints}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
/>
|
||||
{model.files.flatMap((file) => [
|
||||
<NativeStickyFileHeader
|
||||
{interactionFiles.map((file) => (
|
||||
<NativeCanvasFileHeader
|
||||
key={`${file.path}:header`}
|
||||
file={file}
|
||||
viewportWidth={model.viewportWidth}
|
||||
scrollTop={scrollTop}
|
||||
textLayout={headerTextLayout}
|
||||
paints={paints}
|
||||
headerSurface={props.palette.headerSurface}
|
||||
headerActiveSurface={props.palette.headerActiveSurface}
|
||||
selectedPath={props.selectedPath}
|
||||
mode={props.mode}
|
||||
onToggleFile={props.onToggleFile}
|
||||
onSelectPath={props.onSelectPath}
|
||||
/>,
|
||||
/>
|
||||
))}
|
||||
{interactionFiles.map((file) => (
|
||||
<NativeFileBody
|
||||
key={`${file.path}:body`}
|
||||
file={file}
|
||||
model={model}
|
||||
mode={props.mode}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
/>,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
<NativeReviewOverlays model={model} mode={props.mode} />
|
||||
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
|
||||
</AnimatedScrollView>
|
||||
@@ -306,29 +356,69 @@ function NativeCanvasSlabLayer({
|
||||
));
|
||||
}
|
||||
|
||||
function NativeStickyFileHeader({
|
||||
function NativeCanvasFileHeader({
|
||||
file,
|
||||
viewportWidth,
|
||||
scrollTop,
|
||||
textLayout,
|
||||
paints,
|
||||
headerSurface,
|
||||
headerActiveSurface,
|
||||
selectedPath,
|
||||
mode,
|
||||
onToggleFile,
|
||||
onSelectPath,
|
||||
}: {
|
||||
file: DiffFileSection;
|
||||
viewportWidth: number;
|
||||
scrollTop: SharedValue<number>;
|
||||
textLayout: NativeHeaderTextLayout;
|
||||
paints: NativePaints;
|
||||
headerSurface: string;
|
||||
headerActiveSurface: string;
|
||||
selectedPath: DiffSurfaceProps["selectedPath"];
|
||||
mode: DiffSurfaceProps["mode"];
|
||||
onToggleFile: DiffSurfaceProps["onToggleFile"];
|
||||
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 (
|
||||
<View style={styles.header}>
|
||||
<Animated.View style={[style, stickyStyle]}>
|
||||
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||
<Picture picture={picture} />
|
||||
</Canvas>
|
||||
<DocumentFileHeader
|
||||
file={file}
|
||||
selectedPath={selectedPath}
|
||||
mode={mode}
|
||||
onToggleFile={onToggleFile}
|
||||
onSelectPath={onSelectPath}
|
||||
canvasRendered
|
||||
onActiveChange={setActive}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,7 +472,10 @@ function NativeFileBody({
|
||||
<View
|
||||
testID={`diff-file-${file.fileIndex}-body`}
|
||||
style={inlineUnistylesStyle<ViewStyle>({
|
||||
position: "relative",
|
||||
position: "absolute",
|
||||
top: file.bodyTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: file.bodyHeight,
|
||||
zIndex: 2,
|
||||
})}
|
||||
@@ -579,5 +672,4 @@ function NativeSlabCode({
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
||||
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 type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import {
|
||||
diffHeaderViewportKey,
|
||||
diffInteractionWindowTop,
|
||||
diffMaterializationWindow,
|
||||
resolveVisibleFileSections,
|
||||
} from "./header-layout";
|
||||
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
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 { createMeasuredAdvances } from "./text-measurement";
|
||||
import { retainDiffViewport } from "./viewport";
|
||||
@@ -30,6 +36,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const workspaceCache = useDiffDocumentWorkspaceCache();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const headerCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
@@ -46,6 +53,17 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
dismissSelectionOnClick: boolean;
|
||||
} | 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 resizeReleaseFrameRef = useRef<number | null>(null);
|
||||
const resizePointerActiveRef = useRef(false);
|
||||
@@ -53,6 +71,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const forcePaintRef = useRef(true);
|
||||
const canvasWindowRef = useRef({ top: 0, height: 0 });
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [fileWindowTop, setFileWindowTop] = useState(0);
|
||||
const [interactionFiles, setInteractionFiles] = useState<
|
||||
ReturnType<typeof buildDiffDocumentModel>["files"]
|
||||
>([]);
|
||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||
hit: Extract<DiffHit, { kind: "cell" }>;
|
||||
left: number;
|
||||
@@ -81,12 +103,18 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
await Promise.all([
|
||||
document.fonts.load(`400 ${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;
|
||||
},
|
||||
createMeasurer: () => createWebTextMeasurer(desiredTypography),
|
||||
}),
|
||||
[desiredTypography, workspaceCache],
|
||||
[desiredTypography, props.headerTypography, workspaceCache],
|
||||
);
|
||||
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
|
||||
typeof workspaceCache.typography
|
||||
@@ -105,7 +133,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
lineHeight: desiredTypography.lineHeight,
|
||||
});
|
||||
}
|
||||
return workspaceCache.buildModel({
|
||||
const next = workspaceCache.buildModel({
|
||||
files: props.files,
|
||||
collapsedFilePaths: props.collapsedFilePaths,
|
||||
layout: props.displayPreferences.layout,
|
||||
@@ -119,8 +147,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
});
|
||||
return next;
|
||||
}, [
|
||||
fileWindowTop,
|
||||
measurement,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
@@ -132,6 +163,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
desiredTypography.lineHeight,
|
||||
loadedTypography,
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
workspaceCache,
|
||||
]);
|
||||
modelRef.current = model;
|
||||
@@ -171,6 +203,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${currentModel.viewportWidth}px`;
|
||||
canvas.style.top = `${canvasTop}px`;
|
||||
canvas.style.height = `${canvasHeight}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
@@ -212,6 +245,64 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
},
|
||||
[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(() => {
|
||||
const root = rootRef.current;
|
||||
@@ -299,13 +390,37 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
props.files.map((file) => file.path),
|
||||
);
|
||||
}, [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(
|
||||
() => () => {
|
||||
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
|
||||
// request so the refreshed renderer can schedule its first paint.
|
||||
frameRef.current = null;
|
||||
headerFrameRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -330,6 +445,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
(scrollElement: HTMLDivElement) => {
|
||||
const scrollTop = scrollElement.scrollTop;
|
||||
scrollTopRef.current = scrollTop;
|
||||
if (activeHeaderPathRef.current !== null) activeHeaderPathRef.current = null;
|
||||
scheduleHeaderPaint();
|
||||
updateInteractionFiles(scrollTop);
|
||||
if (hasHoveredAffordanceRef.current) {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
setHoveredAffordance(null);
|
||||
@@ -354,7 +472,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const nextTop = Math.round(requestedTop * ratio) / ratio;
|
||||
if (nextTop !== currentWindow.top) schedulePaint(false);
|
||||
},
|
||||
[schedulePaint, viewport.height],
|
||||
[scheduleHeaderPaint, schedulePaint, updateInteractionFiles, viewport.height],
|
||||
);
|
||||
useEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -419,8 +537,20 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
},
|
||||
[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(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
updateActiveHeader(event.target);
|
||||
const drag = dragRef.current;
|
||||
if (drag) {
|
||||
drag.moved = hasPointerDragStarted({
|
||||
@@ -462,7 +592,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
|
||||
schedulePaint();
|
||||
},
|
||||
[pointHit, schedulePaint, viewport.width],
|
||||
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
|
||||
);
|
||||
const pointerUp = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -504,6 +634,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const cancelPointer = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]);
|
||||
const copy = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (!selectionRef.current) return;
|
||||
@@ -535,11 +666,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const canvasStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
...CANVAS_STYLE,
|
||||
width: model.viewportWidth,
|
||||
fontFamily: (loadedTypography ?? desiredTypography).family,
|
||||
fontSize: (loadedTypography ?? desiredTypography).size,
|
||||
}),
|
||||
[desiredTypography, loadedTypography, model.viewportWidth],
|
||||
[desiredTypography, loadedTypography],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -554,12 +684,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
onPointerUp={pointerUp}
|
||||
onPointerCancel={cancelPointer}
|
||||
onLostPointerCapture={cancelPointer}
|
||||
onPointerLeave={pointerLeave}
|
||||
onCopy={copy}
|
||||
style={SCROLL_STYLE}
|
||||
>
|
||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||
{model.files.map((file) => (
|
||||
{interactionFiles.map((file) => (
|
||||
<WebFileHeaderSection key={file.path} file={file}>
|
||||
<DocumentFileHeader
|
||||
file={file}
|
||||
@@ -567,10 +698,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
mode={props.mode}
|
||||
onToggleFile={props.onToggleFile}
|
||||
onSelectPath={props.onSelectPath}
|
||||
canvasRendered
|
||||
/>
|
||||
</WebFileHeaderSection>
|
||||
))}
|
||||
{model.files
|
||||
{interactionFiles
|
||||
.filter((file) => !file.isCollapsed && !model.wrapLines)
|
||||
.map((file) => (
|
||||
<HorizontalScroll
|
||||
@@ -609,6 +741,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<canvas
|
||||
ref={headerCanvasRef}
|
||||
data-testid="git-diff-header-canvas"
|
||||
style={HEADER_CANVAS_STYLE}
|
||||
/>
|
||||
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
||||
{hoveredAffordance?.hit.target && reviewActions ? (
|
||||
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
||||
@@ -686,6 +823,7 @@ function WebFileHeaderSection({
|
||||
<div style={style}>
|
||||
<div
|
||||
data-diff-header="true"
|
||||
data-diff-header-path={file.path}
|
||||
style={STICKY_HEADER_STYLE}
|
||||
onMouseDown={preventDocumentMouseSelection}
|
||||
>
|
||||
@@ -766,6 +904,7 @@ const FILE_SECTION_STYLE: React.CSSProperties = {
|
||||
right: 0,
|
||||
zIndex: 3,
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
};
|
||||
const STICKY_HEADER_STYLE: React.CSSProperties = {
|
||||
position: "sticky",
|
||||
@@ -789,6 +928,12 @@ const CANVAS_STYLE: React.CSSProperties = {
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const HEADER_CANVAS_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 3,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const AFFORDANCE_STYLE: ViewStyle = {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
@@ -808,6 +953,7 @@ function emptyDiffDocumentModel(input: {
|
||||
layout: input.layout,
|
||||
wrapLines: input.wrapLines,
|
||||
viewportWidth: input.viewportWidth,
|
||||
reviewGeometryKey: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,66 @@ export interface NativeTextLayout {
|
||||
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 {
|
||||
font: SkFont;
|
||||
asciiMetrics: CachedAsciiTextMetrics;
|
||||
|
||||
@@ -61,9 +61,20 @@ export interface DiffPalette {
|
||||
deletionBackground: string;
|
||||
emptyBackground: string;
|
||||
selection: string;
|
||||
headerActiveSurface: string;
|
||||
headerBorder: string;
|
||||
statusSuccess: string;
|
||||
statusDanger: string;
|
||||
statusWarning: string;
|
||||
syntax: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DiffHeaderTypography {
|
||||
family: string;
|
||||
size: number;
|
||||
statSize: number;
|
||||
}
|
||||
|
||||
export interface DiffTokenRun {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -150,6 +161,7 @@ export interface DiffDocumentModel {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
viewportWidth: number;
|
||||
reviewGeometryKey: string;
|
||||
}
|
||||
|
||||
export interface TextMeasurer {
|
||||
@@ -168,6 +180,7 @@ export interface BuildDiffDocumentModelInput {
|
||||
palette: DiffPalette;
|
||||
reviewActions?: InlineReviewActions;
|
||||
labels: { binary: string; tooLarge: string };
|
||||
materializationWindow?: { top: number; height: number };
|
||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||
reuseFrom?: readonly DiffDocumentModel[];
|
||||
}
|
||||
@@ -206,6 +219,7 @@ export type DiffScrollAnchor =
|
||||
|
||||
export type DiffSurfaceProps = DiffDocumentProps & {
|
||||
palette: DiffPalette;
|
||||
headerTypography: DiffHeaderTypography;
|
||||
collapsedFilePaths: ReadonlySet<string>;
|
||||
onToggleFile: (path: string) => void;
|
||||
selectedPath: string | null;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
|
||||
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
|
||||
import type {
|
||||
BuildDiffDocumentModelInput,
|
||||
DiffDocumentModel,
|
||||
DiffPalette,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
const palette: DiffPalette = {
|
||||
surface: "#000",
|
||||
@@ -15,6 +20,11 @@ const palette: DiffPalette = {
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: { keyword: "purple" },
|
||||
};
|
||||
|
||||
@@ -137,6 +147,33 @@ describe("diff document workspace cache", () => {
|
||||
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 () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
@@ -171,3 +208,13 @@ describe("diff document workspace cache", () => {
|
||||
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 invariant from "tiny-invariant";
|
||||
import { buildDiffDocumentModel, retainReusableModels } from "./model";
|
||||
import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model";
|
||||
import type {
|
||||
BuildDiffDocumentModelInput,
|
||||
DiffDocumentModel,
|
||||
@@ -117,17 +117,11 @@ function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">):
|
||||
|
||||
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
|
||||
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
|
||||
const reviewGeometry = input.reviewActions
|
||||
? {
|
||||
comments: [...input.reviewActions.commentsByTarget.entries()]
|
||||
.map(([target, comments]) => [target, comments.map((comment) => comment.id).sort()])
|
||||
.sort(([left], [right]) => String(left).localeCompare(String(right))),
|
||||
editor: input.reviewActions.editor
|
||||
? [input.reviewActions.editor.target.key, input.reviewActions.editor.commentId]
|
||||
: null,
|
||||
}
|
||||
const reviewGeometry = reviewGeometryKey(input.reviewActions);
|
||||
const materializationWindow = input.materializationWindow
|
||||
? [input.materializationWindow.top, input.materializationWindow.height]
|
||||
: null;
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
|
||||
}
|
||||
|
||||
function typographyKey(typography: DiffTypography): string {
|
||||
@@ -150,6 +144,11 @@ function paletteKey(palette: DiffPalette): string {
|
||||
palette.deletionBackground,
|
||||
palette.emptyBackground,
|
||||
palette.selection,
|
||||
palette.headerActiveSurface,
|
||||
palette.headerBorder,
|
||||
palette.statusSuccess,
|
||||
palette.statusDanger,
|
||||
palette.statusWarning,
|
||||
syntax,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
reduceFileHeaderPress,
|
||||
shouldActivateStickyHeaderPress,
|
||||
type FileHeaderPressEvent,
|
||||
type FileHeaderPressPhase,
|
||||
} from "./file-header-interaction";
|
||||
|
||||
const pressOrigin = { startedAt: 1_000, pageX: 20, pageY: 30 };
|
||||
|
||||
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[]) {
|
||||
let phase: FileHeaderPressPhase = "idle";
|
||||
const effects: string[] = [];
|
||||
@@ -57,64 +17,17 @@ describe("file header interaction arbitration", () => {
|
||||
return effects;
|
||||
}
|
||||
|
||||
it("lets a normal press claim a deferred sticky release exactly once", () => {
|
||||
expect(
|
||||
run([
|
||||
{ type: "press-in" },
|
||||
{ type: "press-out", stickyFallbackEligible: true },
|
||||
{ type: "press" },
|
||||
{ type: "fallback" },
|
||||
]),
|
||||
).toEqual(["defer-activate", "activate"]);
|
||||
it("activates an actual press", () => {
|
||||
expect(run([{ type: "press-in" }, { type: "press" }])).toEqual(["activate"]);
|
||||
});
|
||||
|
||||
it("keeps a duplicated wrapper press-out eligible for one fallback", () => {
|
||||
expect(
|
||||
run([
|
||||
{ type: "press-in" },
|
||||
{ type: "press-out", stickyFallbackEligible: true },
|
||||
{ type: "press-out", stickyFallbackEligible: true },
|
||||
{ type: "fallback" },
|
||||
]),
|
||||
).toEqual(["defer-activate", "activate"]);
|
||||
it("does not activate when scrolling cancels the press", () => {
|
||||
expect(run([{ type: "press-in" }])).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back once for a canceled sticky press", () => {
|
||||
expect(
|
||||
run([
|
||||
{ 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"]);
|
||||
it("selects a long press without activating when Pressability later emits press", () => {
|
||||
expect(run([{ type: "press-in" }, { type: "long-press" }, { type: "press" }])).toEqual([
|
||||
"select",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
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 {
|
||||
path: string;
|
||||
@@ -11,47 +7,22 @@ interface FileHeaderInteractionInput {
|
||||
onSelect?: (path: string) => void;
|
||||
onActivate?: (path: string) => 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 {
|
||||
activate: () => void;
|
||||
select: () => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onPressIn: (event: PressPointEvent) => void;
|
||||
onPressOut: (event: PressPointEvent) => void;
|
||||
onPressIn: () => void;
|
||||
onLongPress: () => void;
|
||||
}
|
||||
|
||||
export type FileHeaderPressPhase = "idle" | "pressed" | "released" | "handled" | "long";
|
||||
export type FileHeaderPressPhase = "idle" | "pressed" | "long" | "handled";
|
||||
export type FileHeaderPressEvent =
|
||||
| { type: "press-in" }
|
||||
| { type: "press-out"; stickyFallbackEligible: boolean }
|
||||
| { type: "press" }
|
||||
| { type: "long-press" }
|
||||
| { type: "fallback" };
|
||||
export type FileHeaderPressEffect = "none" | "select" | "activate" | "defer-activate";
|
||||
| { type: "long-press" };
|
||||
export type FileHeaderPressEffect = "none" | "select" | "activate";
|
||||
|
||||
export function reduceFileHeaderPress(
|
||||
phase: FileHeaderPressPhase,
|
||||
@@ -59,62 +30,14 @@ export function reduceFileHeaderPress(
|
||||
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
|
||||
if (event.type === "press-in") return { phase: "pressed", effect: "none" };
|
||||
if (event.type === "long-press") return { phase: "long", effect: "select" };
|
||||
if (event.type === "press-out") {
|
||||
if (phase === "released" || phase === "handled" || phase === "long") {
|
||||
return { phase, effect: "none" };
|
||||
}
|
||||
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
|
||||
);
|
||||
return phase === "long" || phase === "handled"
|
||||
? { phase: "handled", effect: "none" }
|
||||
: { phase: "handled", effect: "activate" };
|
||||
}
|
||||
|
||||
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
|
||||
const {
|
||||
path,
|
||||
enabled,
|
||||
onSelect,
|
||||
onActivate,
|
||||
onLayout: notifyLayout,
|
||||
stickyPressFallback = false,
|
||||
} = input;
|
||||
const pressHandledRef = useRef(false);
|
||||
const pressOriginRef = useRef<PressOrigin | null>(null);
|
||||
const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input;
|
||||
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(() => {
|
||||
if (enabled) onSelect?.(path);
|
||||
@@ -122,14 +45,12 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
||||
|
||||
const activate = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
clearFallback();
|
||||
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
|
||||
phaseRef.current = transition.phase;
|
||||
if (transition.effect !== "activate") return;
|
||||
pressHandledRef.current = true;
|
||||
select();
|
||||
onActivate?.(path);
|
||||
}, [clearFallback, enabled, onActivate, path, select]);
|
||||
}, [enabled, onActivate, path, select]);
|
||||
|
||||
const onLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
@@ -138,61 +59,15 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
||||
[notifyLayout],
|
||||
);
|
||||
|
||||
const onPressIn = useCallback(
|
||||
(event: PressPointEvent) => {
|
||||
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 onPressIn = useCallback(() => {
|
||||
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
|
||||
}, []);
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
clearFallback();
|
||||
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
|
||||
phaseRef.current = transition.phase;
|
||||
pressHandledRef.current = true;
|
||||
if (transition.effect === "select") select();
|
||||
}, [clearFallback, select]);
|
||||
}, [select]);
|
||||
|
||||
const onPressOut = useCallback(
|
||||
(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 };
|
||||
return { activate, select, onLayout, onPressIn, 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 { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -17,6 +22,12 @@ import {
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
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 { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
@@ -43,14 +54,8 @@ export interface FileHeaderProps {
|
||||
onRevert?: (path: string, oldPath?: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function fileNameForPath(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
}
|
||||
|
||||
function directorySuffix(path: string): string {
|
||||
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
|
||||
canvasRendered?: boolean;
|
||||
onActiveChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
function fileHeaderAccessibilityState(input: {
|
||||
@@ -68,12 +73,22 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
||||
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 handlePointerEnter = useCallback(() => {
|
||||
if (enabled) setIsHovered(true);
|
||||
}, [enabled]);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
if (!enabled) return;
|
||||
setIsHovered(true);
|
||||
onActiveChange?.(true);
|
||||
}, [enabled, onActiveChange]);
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
onActiveChange?.(false);
|
||||
}, [onActiveChange]);
|
||||
return { isHovered, handlePointerEnter, handlePointerLeave };
|
||||
}
|
||||
|
||||
@@ -96,7 +111,8 @@ function fileHeaderInteractionStyle(input: {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -115,9 +131,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
||||
}
|
||||
|
||||
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||
if (file.isNew) return "added";
|
||||
if (file.isDeleted) return "deleted";
|
||||
return "modified";
|
||||
return diffFileChangeKind(file);
|
||||
}
|
||||
|
||||
function FileHeaderMenu({
|
||||
@@ -181,9 +195,11 @@ export const FileHeader = memo(function FileHeader({
|
||||
onSelect,
|
||||
onHeaderHeightChange,
|
||||
testID,
|
||||
canvasRendered = false,
|
||||
onActiveChange,
|
||||
...actions
|
||||
}: FileHeaderProps) {
|
||||
const hover = useFileHeaderHover(interactive);
|
||||
const hover = useFileHeaderHover(interactive, onActiveChange);
|
||||
const dragSourceRef = useWorkspaceFileDragSource({
|
||||
enabled: interactive,
|
||||
disabled: file.isDeleted,
|
||||
@@ -196,7 +212,6 @@ export const FileHeader = memo(function FileHeader({
|
||||
enabled: interactive,
|
||||
onSelect,
|
||||
onActivate,
|
||||
stickyPressFallback: showsBodyState,
|
||||
onLayout: useCallback(
|
||||
(height: number) => onHeaderHeightChange?.(file.path, height),
|
||||
[file.path, onHeaderHeightChange],
|
||||
@@ -221,6 +236,13 @@ export const FileHeader = memo(function FileHeader({
|
||||
],
|
||||
[depth, hover.isHovered, isSelected, showsBodyState],
|
||||
);
|
||||
const canvasPressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
pressableStyle(state),
|
||||
canvasRendered && styles.canvasInteraction,
|
||||
],
|
||||
[canvasRendered, pressableStyle],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
||||
[bodyVisible, isSelected, showsBodyState],
|
||||
@@ -260,36 +282,67 @@ export const FileHeader = memo(function FileHeader({
|
||||
</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;
|
||||
if (interactive) {
|
||||
trigger = (
|
||||
<ContextMenuTrigger
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={pressableStyle}
|
||||
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)}
|
||||
cancelable={false}
|
||||
onPressIn={interaction.onPressIn}
|
||||
onPressOut={interaction.onPressOut}
|
||||
style={canvasPressableStyle}
|
||||
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState, canvasRendered)}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onLongPress={interaction.onLongPress}
|
||||
onContextMenu={interaction.select}
|
||||
onPress={interaction.activate}
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{content}
|
||||
{renderedContent}
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
} else {
|
||||
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>;
|
||||
trigger = (
|
||||
<View
|
||||
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{renderedContent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, fileHeaderContainerStyle(showsBodyState)]}
|
||||
style={[
|
||||
styles.container,
|
||||
fileHeaderContainerStyle(showsBodyState),
|
||||
canvasRendered && styles.canvasInteraction,
|
||||
]}
|
||||
onLayout={interaction.onLayout}
|
||||
onPointerEnter={hover.handlePointerEnter}
|
||||
onPointerLeave={hover.handlePointerLeave}
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
<ContextMenu>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -347,6 +400,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
documentActive: { backgroundColor: theme.colors.surface1 },
|
||||
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
|
||||
canvasInteraction: {
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "transparent",
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
canvasInteractionContent: { flex: 1, minWidth: 0 },
|
||||
left: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -28,3 +28,11 @@ export function resolveKeyboardShift(input: {
|
||||
|
||||
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 {
|
||||
shouldReconcileHiddenKeyboardEnd,
|
||||
resolveKeyboardShift,
|
||||
shouldUseCompactExplorerKeyboardPadding,
|
||||
} 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", () => {
|
||||
it("keeps the changes viewport stable while preserving padding for other tabs", () => {
|
||||
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||
resolveKeyboardShift,
|
||||
shouldReconcileHiddenKeyboardEnd,
|
||||
} from "@/hooks/keyboard-shift-policy";
|
||||
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
@@ -34,9 +35,9 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
{
|
||||
onEnd: (event) => {
|
||||
"worklet";
|
||||
if (isIos) {
|
||||
keyboardHeight.value = -event.height;
|
||||
keyboardProgress.value = event.progress;
|
||||
if (isIos && shouldReconcileHiddenKeyboardEnd(event)) {
|
||||
keyboardHeight.value = 0;
|
||||
keyboardProgress.value = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ar: TranslationResources = {
|
||||
empty: "ابدأ الدردشة مع هذا الوكيل...",
|
||||
scrollToBottom: "قم بالتمرير إلى الأسفل",
|
||||
historyLoadFailed: "تعذر تحميل سجل الوكيل",
|
||||
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
|
||||
permission: {
|
||||
plan: "يخطط",
|
||||
required: "الإذن مطلوب",
|
||||
|
||||
@@ -200,6 +200,7 @@ export const en = {
|
||||
empty: "Start chatting with this agent...",
|
||||
scrollToBottom: "Scroll to bottom",
|
||||
historyLoadFailed: "Couldn't load agent history",
|
||||
messageCapped: "This message was capped ({{bytes}} bytes).",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "Permission Required",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const es: TranslationResources = {
|
||||
empty: "Comience a chatear con este agente...",
|
||||
scrollToBottom: "Desplazarse hacia abajo",
|
||||
historyLoadFailed: "No se pudo cargar el historial del agente",
|
||||
messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "Permiso requerido",
|
||||
|
||||
@@ -205,6 +205,7 @@ export const fr: TranslationResources = {
|
||||
empty: "Commencez à discuter avec cet agent...",
|
||||
scrollToBottom: "Faire défiler vers le bas",
|
||||
historyLoadFailed: "Impossible de charger l’historique de l’agent",
|
||||
messageCapped: "Ce message a été tronqué ({{bytes}} octets).",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "Autorisation requise",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ja: TranslationResources = {
|
||||
empty: "このエージェントとチャットを始めましょう...",
|
||||
scrollToBottom: "下にスクロール",
|
||||
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
|
||||
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
|
||||
permission: {
|
||||
plan: "プラン",
|
||||
required: "権限が必要です",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ko: TranslationResources = {
|
||||
empty: "이 에이전트와 대화를 시작하세요...",
|
||||
scrollToBottom: "맨 아래로 스크롤",
|
||||
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
|
||||
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
|
||||
permission: {
|
||||
plan: "계획",
|
||||
required: "권한 필요",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ptBR: TranslationResources = {
|
||||
empty: "Comece a conversar com este agente...",
|
||||
scrollToBottom: "Rolar para o fim",
|
||||
historyLoadFailed: "Não foi possível carregar o histórico do agente",
|
||||
messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).",
|
||||
permission: {
|
||||
plan: "Plano",
|
||||
required: "Permissão necessária",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ru: TranslationResources = {
|
||||
empty: "Начните общаться с этим агентом...",
|
||||
scrollToBottom: "Прокрутить вниз",
|
||||
historyLoadFailed: "Не удалось загрузить историю агента",
|
||||
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
|
||||
permission: {
|
||||
plan: "План",
|
||||
required: "Требуется разрешение",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const zhCN: TranslationResources = {
|
||||
empty: "开始和这个 Agent 对话...",
|
||||
scrollToBottom: "滚动到底部",
|
||||
historyLoadFailed: "无法加载智能体历史记录",
|
||||
messageCapped: "此消息已被截断({{bytes}} 字节)。",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "需要权限",
|
||||
|
||||
@@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
|
||||
startedRevision.value = beginGesture({ origin: "agent" });
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
||||
@@ -188,7 +188,6 @@ export function useCloseAgentListGesture() {
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({
|
||||
origin: "agent-list",
|
||||
preview: "agent-list",
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -290,7 +289,6 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({
|
||||
origin: "agent",
|
||||
preview: "file-explorer",
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -386,7 +384,6 @@ export function useCloseFileExplorerGesture() {
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({
|
||||
origin: "file-explorer",
|
||||
preview: "file-explorer",
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
canBeginMobilePanelGesture,
|
||||
createMobilePanelMotionState,
|
||||
getMobilePanelFrame,
|
||||
isMobilePanelActive,
|
||||
isMobilePanelGestureCurrent,
|
||||
transitionMobilePanel,
|
||||
type MobilePanelCommit,
|
||||
@@ -53,8 +54,8 @@ class MobilePanelsScenario {
|
||||
return this;
|
||||
}
|
||||
|
||||
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
|
||||
this.dispatch({ type: "animation.finished", revision, target });
|
||||
settleAt(position: number) {
|
||||
this.dispatch({ type: "position.changed", position });
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -77,10 +78,107 @@ class MobilePanelsScenario {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.command("agent-list").finishAnimation("agent-list");
|
||||
panels.command("agent-list").settleAt(-1);
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "agent-list",
|
||||
motionTarget: "agent-list",
|
||||
@@ -88,7 +186,7 @@ describe("mobile panel ownership", () => {
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
|
||||
panels.command("agent").command("file-explorer").settleAt(1);
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "file-explorer",
|
||||
motionTarget: "file-explorer",
|
||||
@@ -139,13 +237,12 @@ describe("mobile panel ownership", () => {
|
||||
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();
|
||||
|
||||
panels.command("agent-list");
|
||||
const staleRevision = panels.snapshot().revision;
|
||||
panels.command("agent").command("file-explorer");
|
||||
panels.finishAnimation("agent-list", staleRevision);
|
||||
panels.settleAt(-1);
|
||||
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "file-explorer",
|
||||
|
||||
@@ -21,6 +21,32 @@ export interface MobilePanelTransition {
|
||||
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 =
|
||||
| { type: "command"; selection: MobilePanelSelection }
|
||||
| { type: "gesture.begin"; origin: MobilePanelView }
|
||||
@@ -30,7 +56,7 @@ export type MobilePanelEvent =
|
||||
success: boolean;
|
||||
target: MobilePanelView;
|
||||
}
|
||||
| { type: "animation.finished"; revision: number; target: MobilePanelView };
|
||||
| { type: "position.changed"; position: number };
|
||||
|
||||
export function createMobilePanelMotionState(
|
||||
selection: MobilePanelSelection,
|
||||
@@ -52,8 +78,9 @@ export function transitionMobilePanel(
|
||||
if (event.selection.revision <= state.revision) {
|
||||
return { state };
|
||||
}
|
||||
const continuesCurrentMotion = state.motionTarget === event.selection.target;
|
||||
return {
|
||||
animationTarget: event.selection.target,
|
||||
animationTarget: continuesCurrentMotion ? undefined : event.selection.target,
|
||||
state: {
|
||||
...state,
|
||||
...event.selection,
|
||||
@@ -101,26 +128,7 @@ export function transitionMobilePanel(
|
||||
};
|
||||
}
|
||||
|
||||
const ownsCurrentRevision = event.revision === state.revision;
|
||||
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;
|
||||
return settleMobilePanelAtPosition(state, event.position);
|
||||
}
|
||||
|
||||
export function canBeginMobilePanelGesture(
|
||||
@@ -143,6 +151,13 @@ export function isMobilePanelGestureCurrent(
|
||||
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) {
|
||||
"worklet";
|
||||
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 { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
||||
import { getMobilePanelFrame } from "./model";
|
||||
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
|
||||
import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider";
|
||||
|
||||
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
||||
|
||||
@@ -24,10 +24,8 @@ export function MobilePanelOverlay({
|
||||
panelStyle,
|
||||
}: MobilePanelOverlayProps) {
|
||||
const { position, windowWidth } = useMobilePanelsRuntime();
|
||||
const target = usePanelStore((state) => state.mobilePanel.target);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const isOpen = target === panel;
|
||||
const isPresented = useIsMobilePanelPresented(panel);
|
||||
const isOpen = useIsMobilePanelActive(panel);
|
||||
const isLeft = panel === "agent-list";
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
||||
@@ -42,10 +40,11 @@ export function MobilePanelOverlay({
|
||||
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
||||
}, [isLeft, windowWidth]);
|
||||
|
||||
const overlayStyle = useMemo(
|
||||
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
|
||||
[isPresented],
|
||||
);
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const isVisible = isLeft ? position.value < 0 : position.value > 0;
|
||||
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||
}, [isLeft]);
|
||||
|
||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||
const backdropStyle = useMemo(
|
||||
() => [styles.backdrop, backdropAnimatedStyle],
|
||||
@@ -73,19 +72,25 @@ export function MobilePanelOverlay({
|
||||
{/* Fabric needs an always-mounted native host to attach the close handler while the
|
||||
retained panel content is hidden. nativeID keeps that host registered. */}
|
||||
<View
|
||||
accessibilityElementsHidden={!isOpen}
|
||||
collapsable={false}
|
||||
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
|
||||
nativeID={`${panel}-gesture-host`}
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={styles.overlay}
|
||||
>
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={[styles.overlay, overlayAnimatedStyle]}
|
||||
>
|
||||
<Pressable
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
onPress={showMobileAgent}
|
||||
pointerEvents={isOpen ? "auto" : "none"}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
testID={`${panel}-backdrop`}
|
||||
testID={isOpen ? `${panel}-backdrop` : undefined}
|
||||
>
|
||||
<Animated.View pointerEvents="none" style={backdropStyle} />
|
||||
</Pressable>
|
||||
@@ -93,7 +98,7 @@ export function MobilePanelOverlay({
|
||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { GestureType } from "react-native-gesture-handler";
|
||||
import {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
type SharedValue,
|
||||
@@ -39,18 +40,6 @@ import {
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
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 {
|
||||
beginGesture: (input: BeginGestureInput) => number;
|
||||
@@ -69,7 +58,6 @@ interface MobilePanelsRuntime {
|
||||
|
||||
interface BeginGestureInput {
|
||||
origin: MobilePanelView;
|
||||
preview: MobilePanelView;
|
||||
}
|
||||
|
||||
interface FinishGestureInput {
|
||||
@@ -79,7 +67,7 @@ interface FinishGestureInput {
|
||||
}
|
||||
|
||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||
const MobilePanelPresentationContext = createContext(0);
|
||||
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
|
||||
|
||||
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
@@ -92,7 +80,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightOpenGestureRef = 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(
|
||||
(owner: symbol, blocked: boolean) => {
|
||||
@@ -106,21 +94,33 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
[openGesturesBlocked],
|
||||
);
|
||||
|
||||
const presentPanel = useCallback((panel: MobilePanelView) => {
|
||||
const mask = getPanelMask(panel);
|
||||
if (mask) {
|
||||
setPresentedPanels((current) => current | mask);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
|
||||
const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => {
|
||||
const selection = usePanelStore.getState().mobilePanel;
|
||||
if (selection.revision !== revision || selection.target !== panel) {
|
||||
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(
|
||||
(transition: MobilePanelTransition) => {
|
||||
"worklet";
|
||||
@@ -128,29 +128,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
const target = transition.animationTarget;
|
||||
const revision = transition.state.revision;
|
||||
position.value = withTiming(
|
||||
getMobilePanelAnchor(target),
|
||||
{ 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);
|
||||
},
|
||||
);
|
||||
position.value = withTiming(getMobilePanelAnchor(target), {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
},
|
||||
[motionState, position, settlePresentation],
|
||||
[position],
|
||||
);
|
||||
|
||||
const applySelection = useCallback(
|
||||
@@ -176,18 +159,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
if (selection === previousState.mobilePanel) {
|
||||
return;
|
||||
}
|
||||
if (selection.target !== "agent") {
|
||||
presentPanel(selection.target);
|
||||
if (isNative) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}
|
||||
scheduleOnUI(applySelection, selection);
|
||||
});
|
||||
}, [applySelection, presentPanel]);
|
||||
}, [applySelection]);
|
||||
|
||||
const beginGesture = useCallback(
|
||||
({ origin, preview }: BeginGestureInput): number => {
|
||||
({ origin }: BeginGestureInput): number => {
|
||||
"worklet";
|
||||
const currentState = motionState.value;
|
||||
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
||||
@@ -199,10 +176,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
motionState.value = transition.state;
|
||||
cancelAnimation(position);
|
||||
scheduleOnRN(presentPanel, preview);
|
||||
return transition.state.gesture?.startedRevision ?? -1;
|
||||
},
|
||||
[motionState, position, presentPanel],
|
||||
[motionState, position],
|
||||
);
|
||||
|
||||
const updateGesture = useCallback(
|
||||
@@ -266,9 +242,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<MobilePanelsContext.Provider value={value}>
|
||||
<MobilePanelPresentationContext.Provider value={presentedPanels}>
|
||||
<MobilePanelActiveContext.Provider value={activePanel}>
|
||||
{children}
|
||||
</MobilePanelPresentationContext.Provider>
|
||||
</MobilePanelActiveContext.Provider>
|
||||
</MobilePanelsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -282,9 +258,8 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
|
||||
const presentedPanels = useContext(MobilePanelPresentationContext);
|
||||
return (presentedPanels & getPanelMask(panel)) !== 0;
|
||||
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
|
||||
return useContext(MobilePanelActiveContext) === panel;
|
||||
}
|
||||
|
||||
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user