mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c581c2486 | ||
|
|
4e9dbc7f54 | ||
|
|
c33c1d0855 | ||
|
|
770648327f | ||
|
|
2a2362f5cf | ||
|
|
6e57bb1e4f | ||
|
|
6db8bd124f | ||
|
|
eb1fe10375 | ||
|
|
db8c22e135 | ||
|
|
0c38afa76f | ||
|
|
7b813f7f58 | ||
|
|
d014cfdb2f | ||
|
|
ec5ab3fc4d | ||
|
|
69510ac72f | ||
|
|
433e67b18b | ||
|
|
d311797653 | ||
|
|
0b3d82a469 | ||
|
|
509a3e17bd | ||
|
|
19bd6e5909 | ||
|
|
16bfff8e0f | ||
|
|
fdf3b4b47f | ||
|
|
96ea6698d7 | ||
|
|
85cd969321 | ||
|
|
c172076bff | ||
|
|
613cbbe9ef | ||
|
|
da8c1b5c94 | ||
|
|
18aaec2772 | ||
|
|
158525624f | ||
|
|
47171b419b | ||
|
|
f809651876 |
@@ -152,6 +152,14 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Install ripgrep (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
- name: Install ripgrep (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: choco install ripgrep --yes --no-progress
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
|
||||
+61
-99
@@ -1,142 +1,104 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thank you for taking the time to contribute to Paseo.
|
||||
This guide is here to save us both time and help you find a useful way to contribute.
|
||||
|
||||
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
**Paseo has a lean, opinionated core built to be extended.**
|
||||
|
||||
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
|
||||
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
## Report bugs in GitHub issues or Discord
|
||||
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
|
||||
|
||||
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
|
||||
## Build a plugin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Feature requests opened as issues will get closed automatically
|
||||
Most specialized workflows and integrations are better served by plugins.
|
||||
|
||||
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
|
||||
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
|
||||
|
||||
You may submit a PR to fix a bug, please read the PR guidelines below.
|
||||
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
|
||||
|
||||
## Product discussions go in GitHub Discussions or Discord
|
||||
## Report bugs
|
||||
|
||||
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
|
||||
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
|
||||
|
||||
Include:
|
||||
|
||||
- What you did, what you expected, and what happened.
|
||||
- Reproduction steps, Paseo version, and platform.
|
||||
- Relevant logs, screenshots, or a recording.
|
||||
|
||||
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
|
||||
|
||||
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
|
||||
|
||||
## Discuss workflows before proposing features
|
||||
|
||||
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
|
||||
|
||||
Explain:
|
||||
|
||||
- What are you trying to do?
|
||||
- How do you do it today?
|
||||
- Where does Paseo get in the way?
|
||||
- What would the flow look like if it worked for you?
|
||||
- What would a better workflow look like?
|
||||
|
||||
"Can you build X" is not useful because it doesn't tell me about your problem.
|
||||
This helps other people describe their needs and gives me useful context when deciding what to build.
|
||||
|
||||
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
|
||||
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
|
||||
|
||||
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
|
||||
## If you still want to submit a pull request
|
||||
|
||||
## Pull requests
|
||||
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
|
||||
|
||||
For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).
|
||||
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
|
||||
|
||||
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
|
||||
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
|
||||
|
||||
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
|
||||
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
|
||||
|
||||
Here is the criteria I use to decide:
|
||||
If you choose to submit a PR, these are the basics to cover.
|
||||
|
||||
✅ Likely to be accepted
|
||||
### What I look for in a core feature
|
||||
|
||||
- Fits the product vision
|
||||
- One focused change
|
||||
- Links the bug it fixes, or the discussion behind it if there is one
|
||||
- Addresses bot reviews
|
||||
- Explains the problem you're solving
|
||||
- QA evidence
|
||||
- Automated tests
|
||||
- Screenshots or video for UI changes, on every affected platform
|
||||
- Says which platforms you tested and which you didn't
|
||||
- Maintainer edits enabled
|
||||
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
|
||||
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
|
||||
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
|
||||
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
|
||||
|
||||
⛔️ Will be rejected
|
||||
### Keep the scope small
|
||||
|
||||
- Bundles unrelated changes
|
||||
- Fails checks
|
||||
- Ignores bot reviews
|
||||
- Takes a feature or the design in a direction I don't want
|
||||
- No QA evidence
|
||||
- No tests
|
||||
- Clearly fully AI-generated PR
|
||||
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
|
||||
|
||||
### What to expect
|
||||
### QA evidence
|
||||
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
|
||||
|
||||
## QA evidence
|
||||
Explain the problem and link the relevant bug report or discussion. Include:
|
||||
|
||||
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
|
||||
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
|
||||
- The commands you ran and their output.
|
||||
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
|
||||
- The platforms you tested and any affected platforms you could not test.
|
||||
|
||||
Pull requests without evidence will be closed.
|
||||
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
|
||||
|
||||
What's accepted:
|
||||
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
|
||||
|
||||
- The shell commands you ran, pasted with their output
|
||||
- The tests you added and their results
|
||||
- Before and after screenshots
|
||||
- A video of the whole interaction
|
||||
- Logs, requests, responses
|
||||
**Satisfying all of the above does not mean your pull request will be merged.**
|
||||
|
||||
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
|
||||
### What happens to your contribution
|
||||
|
||||
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
|
||||
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
|
||||
|
||||
## I want to become a maintainer
|
||||
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
|
||||
|
||||
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
|
||||
## Help the community
|
||||
|
||||
## FAQ
|
||||
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
|
||||
|
||||
### I'm blocked, where do I go?
|
||||
|
||||
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
|
||||
|
||||
### Do you prefer Github or Discord?
|
||||
|
||||
I am more active on Discord for general questions.
|
||||
|
||||
I prefer Github for bugs because the template helps collect information more efficiently.
|
||||
|
||||
### Why was my feature request closed?
|
||||
|
||||
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
|
||||
|
||||
### Why didn't my PR get a detailed review?
|
||||
|
||||
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
|
||||
|
||||
### How long until someone looks at my pull request?
|
||||
|
||||
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
|
||||
|
||||
### Why do you want tests and a video for a small change?
|
||||
|
||||
Because it's faster and safer than me checking out and running every PR myself to test it manually.
|
||||
|
||||
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
|
||||
|
||||
### I only have one platform, can I still contribute?
|
||||
|
||||
Yes. Test what you have, and say what you didn't test.
|
||||
|
||||
### Can I use an agent to write my contribution?
|
||||
|
||||
Yes, as long as you understand what you're submitting and you tested it yourself.
|
||||
|
||||
### Is there a roadmap I can read?
|
||||
|
||||
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
|
||||
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
|
||||
|
||||
@@ -50,11 +50,13 @@ RUN set -eux; \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
ripgrep \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rg --version; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
|
||||
@@ -148,6 +148,10 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
@@ -96,7 +96,8 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
|
||||
[the client contract](protocol-compatibility.md#client-capability-ownership).
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
|
||||
+9
-41
@@ -1,7 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **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.
|
||||
@@ -344,48 +344,16 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute composer pills
|
||||
## Contribute buttons
|
||||
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
|
||||
owns the author API and placement rules. Keep presentation policy in this module so another
|
||||
placement can reuse behavior without copying registration or action state.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
|
||||
+74
-53
@@ -1,81 +1,102 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
|
||||
|
||||
## What is Paseo
|
||||
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
## Agents are the focus
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
|
||||
|
||||
## Core philosophy
|
||||
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
## A low floor and a high ceiling
|
||||
|
||||
## How it works
|
||||
### Easy to start
|
||||
|
||||
### Projects and workspaces
|
||||
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
|
||||
|
||||
### Inside a workspace
|
||||
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
### Room to build your own setup
|
||||
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
|
||||
|
||||
### The daemon
|
||||
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
|
||||
|
||||
This architecture means:
|
||||
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when a client disconnects — the daemon owns them, not the client
|
||||
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
|
||||
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
|
||||
|
||||
## Target user
|
||||
## Freedom, ownership, and privacy
|
||||
|
||||
Anyone who builds software:
|
||||
Paseo's flexibility depends on keeping control with the user:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
|
||||
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
|
||||
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
|
||||
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
|
||||
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
|
||||
|
||||
## What compounds over time
|
||||
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
## A lean core that keeps improving
|
||||
|
||||
## Strategic bets
|
||||
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
|
||||
|
||||
## Current state (May 2026)
|
||||
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
|
||||
|
||||
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
|
||||
|
||||
## Extensibility and composition
|
||||
|
||||
Plugins are a central part of how Paseo gains capability.
|
||||
|
||||
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
|
||||
|
||||
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
|
||||
|
||||
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
|
||||
|
||||
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
|
||||
|
||||
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
|
||||
|
||||
## How Paseo develops
|
||||
|
||||
Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
|
||||
|
||||
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
|
||||
|
||||
- Can people discover and understand it?
|
||||
- Does it work well in their actual workflows?
|
||||
- What friction or missing capability appears with repeated use?
|
||||
- Does the next addition improve the shared experience or belong in a plugin?
|
||||
|
||||
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
|
||||
|
||||
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
|
||||
|
||||
## An ecosystem people can build on
|
||||
|
||||
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
|
||||
|
||||
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
|
||||
|
||||
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
|
||||
|
||||
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
|
||||
|
||||
@@ -38,6 +38,18 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
The client package advertises the protocol behavior it implements. Add each new capability to
|
||||
its exhaustive defaults and implement the associated subscription or decoding behavior there.
|
||||
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
|
||||
automation, or explicit overrides. A schema accepting a message does not establish support for
|
||||
its delivery semantics.
|
||||
|
||||
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
@@ -486,6 +486,8 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA=
|
||||
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
|
||||
|
||||
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
await page.getByTestId("command-center-scope").click();
|
||||
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function source(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function openSource(page: Page, filename: string) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expect(source(page)).toBeVisible();
|
||||
}
|
||||
async function findInSource(page: Page, text: string) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill(text);
|
||||
}
|
||||
async function expectQuerySelected(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
await expect
|
||||
.poll(() =>
|
||||
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
|
||||
)
|
||||
.toEqual([0, text.length]);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(source(page)).toBeFocused();
|
||||
}
|
||||
async function expectActiveMatchUncovered(page: Page) {
|
||||
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
|
||||
await expect(match).toBeVisible();
|
||||
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const m = await match.boundingBox();
|
||||
const w = await widget.boundingBox();
|
||||
if (!m || !w) return "missing";
|
||||
const covered =
|
||||
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
|
||||
return covered ? "covered by Find" : "visible";
|
||||
})
|
||||
.toBe("visible");
|
||||
}
|
||||
|
||||
async function findBoxes(page: Page) {
|
||||
const pane = (await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
const widget = (await page
|
||||
.getByLabel("Find", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
return { pane, widget };
|
||||
}
|
||||
|
||||
async function expectFindInsidePane(page: Page) {
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
|
||||
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
|
||||
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
|
||||
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
|
||||
}
|
||||
|
||||
async function expectFindAtTopRight(page: Page) {
|
||||
await expectFindInsidePane(page);
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.y).toBeLessThan(pane.y + 70);
|
||||
}
|
||||
|
||||
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-" });
|
||||
const file = path.join(workspace.repoPath, "source.txt");
|
||||
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "a.b");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("A.B");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("a.b");
|
||||
await expectFindAtTopRight(page);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await query(page).press("Shift+Enter");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
|
||||
await closeFind(page);
|
||||
await expect(query(page)).toBeHidden();
|
||||
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
|
||||
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("first literal");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.screenshot({ path: testInfo.outputPath("replace.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+z");
|
||||
await expect(source(page)).toContainText("first a.b");
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
|
||||
await findInSource(page, "a.b");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
|
||||
await page.getByRole("button", { name: "Replace all", exact: true }).click();
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
|
||||
const file = path.join(workspace.repoPath, "draft.txt");
|
||||
await writeFile(file, "saved on disk\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "draft.txt");
|
||||
await gate.drop();
|
||||
await source(page).fill("unsaved needle\nsecond needle\n");
|
||||
await findInSource(page, "needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
|
||||
gate.restore();
|
||||
});
|
||||
|
||||
test("searches read-only source beyond the viewport without replace controls", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "large.txt"),
|
||||
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "large.txt");
|
||||
await findInSource(page, "needle");
|
||||
await expect(source(page)).toHaveAttribute("contenteditable", "false");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expect(source(page)).toContainText("needle last");
|
||||
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("plain");
|
||||
await expect(status(page)).toHaveText("1 of 10000+");
|
||||
});
|
||||
|
||||
test("targets the focused source in split panes and refocuses an open query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const left = source(page).filter({ hasText: "left needle" });
|
||||
const right = source(page).filter({ hasText: "right needle" });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await left.click();
|
||||
await left.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await query(page).press("Escape");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
|
||||
});
|
||||
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("keeps Find controls inside a narrow source pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
async function selectFirstTwoLines(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Home");
|
||||
await source(page).press("Shift+ArrowDown");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
async function selectLastWord(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+End");
|
||||
await source(page).press("ArrowUp");
|
||||
await source(page).press("Home");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
|
||||
const file = path.join(workspace.repoPath, "selection.txt");
|
||||
await writeFile(file, "alpha\nbeta\nalphabeta\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "selection.txt");
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await query(page).press("Enter");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await closeFind(page);
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+g");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await closeFind(page);
|
||||
|
||||
await selectLastWord(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("alpha");
|
||||
await expect(source(page)).toContainText("beta");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
|
||||
});
|
||||
|
||||
for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
|
||||
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "needle");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
const replacement = page.getByRole("textbox", { name: "Replace with" });
|
||||
await replacement.fill("replacement");
|
||||
|
||||
await test.step("repeat Find from the input and type a fresh query", async () => {
|
||||
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
|
||||
await startingInput.press("End");
|
||||
await startingInput.press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await page.keyboard.type("first");
|
||||
await expect(query(page)).toHaveValue("first");
|
||||
await expect(replacement).toHaveValue("replacement");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const right = source(page).filter({ hasText: "needle one" });
|
||||
await expect(right).toBeVisible();
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
|
||||
|
||||
await test.step("stays visible when Replace expands the widget", async () => {
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
|
||||
});
|
||||
|
||||
await test.step("navigation and query focus still work", async () => {
|
||||
await query(page).press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await query(page).press("Escape");
|
||||
await expect(right).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("Go to line keeps its dialog with Find closed and open", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
|
||||
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "lines.txt");
|
||||
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
|
||||
|
||||
await test.step("with Find closed", async () => {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
|
||||
await gotoInput.fill("12");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
|
||||
await expect(gotoInput).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("with Find open", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("line 3");
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
|
||||
await gotoInput.fill("30");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
|
||||
await expect(query(page)).toHaveValue("line 3");
|
||||
await expect(status(page)).toHaveText("11 matches");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonAuthorExample } from "../support/helpers/plugin-button-example";
|
||||
|
||||
test("the button author example shows each capability without a competing overflow button", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await withButtonAuthorExample(page, info, async (buttons) => {
|
||||
await buttons.refreshFromHeaderAndComposer();
|
||||
await buttons.useMenusAndToggleButtons();
|
||||
await buttons.inspectLiveWorkspaceDetails();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await withButtonShowcase(page, testInfo, async (buttons) => {
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
test,
|
||||
verifyProviderSettings,
|
||||
verifyNewWorkspaceModelIcon,
|
||||
verifyCompactModelIcon,
|
||||
verifyExistingAgentModelIcon,
|
||||
} from "../support/helpers/plugin-provider-icons";
|
||||
|
||||
test("plugin provider icons follow the model through settings and wide and compact composers", async ({
|
||||
providerIcons,
|
||||
}) => {
|
||||
await verifyProviderSettings(providerIcons);
|
||||
await verifyNewWorkspaceModelIcon(providerIcons);
|
||||
await verifyCompactModelIcon(providerIcons);
|
||||
await verifyExistingAgentModelIcon(providerIcons);
|
||||
});
|
||||
@@ -61,12 +61,6 @@ function SidebarCollisionSurface() {
|
||||
return <View><Text>Sidebar collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||
}
|
||||
|
||||
function contributeClient(client) {
|
||||
const pills = new Map();
|
||||
const remove = (agentId) => {
|
||||
@@ -81,23 +75,25 @@ function contributeClient(client) {
|
||||
const agent = update.agent;
|
||||
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||
remove(agent.id);
|
||||
let removePill = () => {};
|
||||
removePill = client.addComposerPill({
|
||||
const pill = client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open composer review",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
button: {
|
||||
title: "Open composer review",
|
||||
icon: "Scan",
|
||||
label: "Review",
|
||||
behavior: { kind: "action", async onPress() {
|
||||
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||
removePill();
|
||||
pill.remove();
|
||||
client.openPanel("agent", {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
} },
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, removePill);
|
||||
pills.set(agent.id, () => pill.remove());
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
@@ -333,9 +329,7 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||
await expect(composerPill).toContainText(
|
||||
"Review Unrelated title update:Plugin panel context agent",
|
||||
);
|
||||
await expect(composerPill).toContainText("Review");
|
||||
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
@@ -41,7 +42,7 @@ async function expectExternalPage(
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||
test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
@@ -62,18 +63,60 @@ test("opens troubleshooting, support, and release destinations", async ({ page }
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
await test.step("opens support and release pages", async () => {
|
||||
await test.step("opens support pages", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the changelog in the app and links the website", async ({ page }) => {
|
||||
// A callout, a section name the app has never seen, and a fenced sample whose
|
||||
// contents look like a release heading.
|
||||
await serveChangelog(page, [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 9.1.0 - 2026-03-04",
|
||||
"",
|
||||
"Headline release note.",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"",
|
||||
"### Sparkles",
|
||||
"",
|
||||
"- Added a brand new thing",
|
||||
"",
|
||||
"```md",
|
||||
"## 0.0.0 - 1999-01-01",
|
||||
"```",
|
||||
"",
|
||||
"## 9.0.0 - 2026-02-01",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed an older thing",
|
||||
"",
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const sheet = await openWhatsNew(page);
|
||||
const latest = release(sheet, "9.1.0");
|
||||
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Headline release note.")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(release(sheet, "9.0.0")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
|
||||
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
|
||||
await closeSheet(page, "changelog-sheet");
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
interface FinishedWorkspaces {
|
||||
subject: MockAgentWorkspace;
|
||||
other: MockAgentWorkspace;
|
||||
}
|
||||
|
||||
const test = base.extend<{ workspaces: FinishedWorkspaces }>({
|
||||
workspaces: async ({ browserName: _browserName }, provide) => {
|
||||
const seeded: MockAgentWorkspace[] = [];
|
||||
async function finishedWorkspace(title: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-mark-unread-",
|
||||
title,
|
||||
initialPrompt: "Finish this turn.",
|
||||
});
|
||||
seeded.push(workspace);
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
||||
return workspace;
|
||||
}
|
||||
try {
|
||||
await provide({
|
||||
subject: await finishedWorkspace("Unread subject"),
|
||||
other: await finishedWorkspace("Other workspace"),
|
||||
});
|
||||
} finally {
|
||||
for (const workspace of seeded) await workspace.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, workspaceId: string) {
|
||||
await workspaceRow(page, workspaceId).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||
}
|
||||
|
||||
async function chooseReadAction(page: Page, workspaceId: string, action: "read" | "unread") {
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`).click();
|
||||
const item = page.getByRole("menuitem", { name: `Mark as ${action}`, exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
async function markAsUnread(page: Page, workspaceId: string) {
|
||||
await chooseReadAction(page, workspaceId, "unread");
|
||||
await expectStatus(page, workspaceId, "attention");
|
||||
}
|
||||
|
||||
async function markAsRead(page: Page, workspaceId: string) {
|
||||
await chooseReadAction(page, workspaceId, "read");
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
}
|
||||
|
||||
async function expectStatus(page: Page, workspaceId: string, status: "done" | "attention") {
|
||||
await expect(
|
||||
workspaceRow(page, workspaceId).getByTestId(`workspace-status-indicator-${status}`),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function markBackgroundWorkspaceAndReopen(page: Page, workspaceId: string) {
|
||||
await test.step("background workspace gains green dot and clears when clicked", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await openWorkspace(page, workspaceId);
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndRead(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("leaving preserves manual unread until Mark as read", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await markAsRead(page, subject.workspaceId);
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("leaving preserves manual unread until reopening", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceOnCompact(page: Page, workspaceId: string) {
|
||||
await openWorkspace(page, workspaceId);
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReadOnCompact(
|
||||
page: Page,
|
||||
{ subject, other }: FinishedWorkspaces,
|
||||
) {
|
||||
await test.step("leaving preserves manual unread until Mark as read", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await markAsRead(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReopenOnCompact(
|
||||
page: Page,
|
||||
{ subject, other }: FinishedWorkspaces,
|
||||
) {
|
||||
await test.step("leaving preserves manual unread until reopening", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function markBackgroundWorkspaceAndRead(page: Page, workspaceId: string) {
|
||||
await test.step("explicit Mark as read clears a background workspace", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await markAsRead(page, workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSelectedAgent(page: Page, agentId: string) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
async function addFinishedAgent(workspace: MockAgentWorkspace) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Newest agent",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
}
|
||||
|
||||
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, workspace);
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
|
||||
await test.step("interacting with the visible chat clears manual unread", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await closeMobileAgentSidebar(page);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).click();
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await leaveMarkedWorkspaceAndRead(page, workspaces);
|
||||
await leaveMarkedWorkspaceAndReopen(page, workspaces);
|
||||
await completeTurnAndLeave(page, workspaces);
|
||||
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
|
||||
});
|
||||
|
||||
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
const newest = await addFinishedAgent(workspaces.subject);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, newest.id);
|
||||
});
|
||||
|
||||
test("manual unread survives leaving the current workspace on compact layout", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await leaveMarkedWorkspaceAndReadOnCompact(page, workspaces);
|
||||
await leaveMarkedWorkspaceAndReopenOnCompact(page, workspaces);
|
||||
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
||||
|
||||
@@ -48,6 +49,20 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
return row;
|
||||
}
|
||||
|
||||
async function openWorkspaceReadAction(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
action: "read" | "unread",
|
||||
) {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
const row = await waitForSidebarWorkspace(page, workspaceId);
|
||||
await row.hover();
|
||||
await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click();
|
||||
const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`);
|
||||
await expect(item).toBeVisible({ timeout: 30_000 });
|
||||
return item;
|
||||
}
|
||||
|
||||
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
|
||||
const row = await waitForSidebarWorkspace(page, workspaceId);
|
||||
await row.hover();
|
||||
@@ -187,6 +202,32 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
title: "Mark unread",
|
||||
initialPrompt: "Finish this test turn.",
|
||||
});
|
||||
|
||||
try {
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
||||
expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible();
|
||||
await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click();
|
||||
await openWorkspaceReadAction(page, workspace.workspaceId, "read");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await openWorkspaceReadAction(page, workspace.workspaceId, "unread");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
|
||||
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
function terminalTab(page: Page, terminalId: string) {
|
||||
// Terminal tab chips have no tab role; their persistent identity survives title loading.
|
||||
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
async function createNamedTerminal(page: Page, workspace: SeededWorkspace, title: string) {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
const list = () =>
|
||||
workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
await expect.poll(async () => (await list()).terminals.length).toBe(1);
|
||||
const terminalId = (await list()).terminals[0]!.id;
|
||||
await terminalTab(page, terminalId).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
||||
const modal = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
await renameModalInput(page, modal).fill(title);
|
||||
await renameModalSubmit(page, modal).click();
|
||||
await expect(renameModalInput(page, modal)).toHaveCount(0);
|
||||
await expect(terminalTab(page, terminalId)).toHaveText(title);
|
||||
return terminalId;
|
||||
}
|
||||
|
||||
async function recordRestoredTabText(page: Page, terminalId: string) {
|
||||
// Install before the next document loads: a final-state assertion would miss the flash.
|
||||
await page.addInitScript((id) => {
|
||||
const samples: string[] = [];
|
||||
Object.assign(window, { terminalTitleSamples: samples });
|
||||
new MutationObserver(() => {
|
||||
const tab = document.querySelector(`[data-testid="workspace-tab-terminal_${id}"]`);
|
||||
if (!tab) return;
|
||||
const text = (tab.textContent ?? "").trim();
|
||||
if (samples.at(-1) !== text) samples.push(text);
|
||||
}).observe(document, { subtree: true, childList: true, characterData: true });
|
||||
}, terminalId);
|
||||
}
|
||||
|
||||
async function expectRestoredTitle(page: Page, terminalId: string, title: string, stage: string) {
|
||||
await expect(terminalTab(page, terminalId)).toHaveText(title, { timeout: 30_000 });
|
||||
const samples = await page.evaluate(
|
||||
() => (window as unknown as { terminalTitleSamples: string[] }).terminalTitleSamples,
|
||||
);
|
||||
await test.info().attach(`${stage}-tab-text`, {
|
||||
body: JSON.stringify(samples),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(samples).toContain(title);
|
||||
expect(samples).not.toContain("Terminal");
|
||||
}
|
||||
|
||||
test("restored terminal titles do not flash a default name while loading", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-title-loading-", git: false });
|
||||
const other = await seedWorkspace({ repoPrefix: "terminal-title-other-", git: false });
|
||||
try {
|
||||
const title = "My named terminal";
|
||||
const terminalId = await createNamedTerminal(page, workspace, title);
|
||||
await recordRestoredTabText(page, terminalId);
|
||||
|
||||
await test.step("reload with a terminal tab restored", async () => {
|
||||
await page.reload();
|
||||
await expectRestoredTitle(page, terminalId, title, "reload");
|
||||
});
|
||||
|
||||
await test.step("first workspace visit in a fresh page session", async () => {
|
||||
await gotoWorkspace(page, other.workspaceId);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await expectRestoredTitle(page, terminalId, title, "first-visit");
|
||||
});
|
||||
|
||||
await test.step("return to the workspace with its titles already loaded", async () => {
|
||||
await selectWorkspaceInSidebar(page, other.workspaceId);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await expectRestoredTitle(page, terminalId, title, "return-visit");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await other.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { prepareContentSearch } from "../support/helpers/workspace-content-search";
|
||||
|
||||
// The missing-rg fixture builds a PATH from `which` and POSIX symlinks. Windows needs its own
|
||||
// PATH construction, which belongs to the CI gate rather than to this journey.
|
||||
base.skip(process.platform === "win32", "the missing-ripgrep PATH fixture is POSIX-shaped");
|
||||
|
||||
const test = base.extend<{}, { searchHostPath: string }>({
|
||||
searchHostPath: [
|
||||
async ({ browserName }, provide) => {
|
||||
void browserName;
|
||||
const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-"));
|
||||
await symlink(
|
||||
execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
|
||||
path.join(bin, "git"),
|
||||
);
|
||||
await symlink(process.execPath, path.join(bin, "node"));
|
||||
try {
|
||||
await provide(bin);
|
||||
} finally {
|
||||
await rm(bin, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
e2eDaemonEnvironment: [
|
||||
async ({ searchHostPath }, provide) => {
|
||||
await provide({ PATH: searchHostPath });
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
test("missing host ripgrep is actionable and Retry works after installation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
searchHostPath,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
await expect(
|
||||
page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByTestId("command-center-panel")
|
||||
.screenshot({ path: testInfo.outputPath("missing-rg.png") });
|
||||
await symlink(
|
||||
execFileSync("which", ["rg"], { encoding: "utf8" }).trim(),
|
||||
path.join(searchHostPath, "rg"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writeFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
|
||||
test("content-search entry and dismissal preserve live shell input and terminal focus", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const harness = await TerminalE2EHarness.create({ tempPrefix: "content-search-terminal-" });
|
||||
try {
|
||||
const terminal = await harness.createTerminal({ name: "Search shortcut QA" });
|
||||
await writeFile(
|
||||
path.join(harness.tempRepo.path, "source.ts"),
|
||||
'export const greeting = "needle";\n',
|
||||
);
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await harness.setupPrompt(page);
|
||||
await searchFromPendingShellCommand(page, harness, testInfo.outputPath("terminal-search.png"));
|
||||
expect(await readFile(path.join(harness.tempRepo.path, "shell-input.txt"), "utf8")).toBe(
|
||||
"untouched",
|
||||
);
|
||||
} finally {
|
||||
await harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function searchFromPendingShellCommand(
|
||||
page: import("@playwright/test").Page,
|
||||
harness: TerminalE2EHarness,
|
||||
screenshot: string,
|
||||
) {
|
||||
const terminal = harness.terminalSurface(page);
|
||||
await terminal.pressSequentially("printf untouched > shell-input.txt", { delay: 0 });
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
const query = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(query).toBeFocused();
|
||||
await query.pressSequentially("needle", { delay: 0 });
|
||||
await expect(page.getByRole("button", { name: /source.ts:1:26/ })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await page.keyboard.press("Enter");
|
||||
await terminal.pressSequentially("printf '\\nSEARCH_SHELL_RESUMED\\n'\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes("SEARCH_SHELL_RESUMED"), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
prepareContentSearch,
|
||||
searchContents,
|
||||
previewAndOpenSecondOccurrence,
|
||||
openCompactPreview,
|
||||
preserveDirtyBufferAndUndo,
|
||||
recoverChangedAndMissingPreview,
|
||||
searchContentsByTouch,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png"));
|
||||
await preserveDirtyBufferAndUndo(page, workspace);
|
||||
});
|
||||
test("compact search keeps list, preview and Open in the existing popup", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await searchContentsByTouch(page, "needle");
|
||||
await openCompactPreview(page, testInfo.outputPath("content-search-compact.png"));
|
||||
});
|
||||
|
||||
test("changed saved files, empty results and occurrence limits stay explicit", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await recoverChangedAndMissingPreview(
|
||||
page,
|
||||
workspace,
|
||||
testInfo.outputPath("content-search-limited.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("a host without content search advertises an update requirement without sending the new RPC", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setWorkspaceContentSearchStripped();
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(
|
||||
page.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("SVG image opening stays visual while a saved occurrence opens exact source", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "icon.svg",
|
||||
content: '<svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
|
||||
result: 'icon.svg:1:48 <svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
|
||||
imageFirst: true,
|
||||
},
|
||||
testInfo.outputPath("svg-source-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("literal leading-space file identity survives preview and exact tab activation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: " leading.txt",
|
||||
content: "needle",
|
||||
result: " leading.txt:1:1 needle",
|
||||
cursor: "Line 1, column 7",
|
||||
},
|
||||
testInfo.outputPath("literal-path-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("CR-only saved occurrences use the source editor line and exact selection", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "cr-only.txt",
|
||||
content: "first\rneedle",
|
||||
result: "cr-only.txt:2:1 needle",
|
||||
cursor: "Line 2, column 7",
|
||||
},
|
||||
testInfo.outputPath("cr-source-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
import {
|
||||
openLiteralBackslashFile,
|
||||
reportSkippedLargeFiles,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => {
|
||||
await reportSkippedLargeFiles(page, await withWorkspace());
|
||||
});
|
||||
|
||||
// "a\b.txt" is one file on POSIX and a nested path on Windows, so only this fixture is unavailable
|
||||
// there. The skip belongs to this test: at file scope it annotates the whole suite and takes every
|
||||
// other journey in this file with it. Windows separator handling is covered by
|
||||
// resolveWorkspaceFilePaths and toWorkspaceRelativePath.
|
||||
test("a file name containing a backslash opens itself, not a same-named nested path", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.skip(
|
||||
process.platform === "win32",
|
||||
"a file name containing a backslash is not a valid Windows fixture",
|
||||
);
|
||||
await openLiteralBackslashFile(page, await withWorkspace());
|
||||
});
|
||||
|
||||
import { reopenSameOccurrenceAfterMovingAway } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("opening the same occurrence again navigates the pane it is already showing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace());
|
||||
});
|
||||
|
||||
import { revealExactPathOnHover } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("a result's tooltip paints the exact path, however deep", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const { rows, tip } = await revealExactPathOnHover(page, await withWorkspace());
|
||||
await rows.first().hover();
|
||||
await expect(tip).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: testInfo.outputPath("tooltip-exact-path.png") });
|
||||
// Pointing must not move the keyboard selection: Enter still opens the first result.
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL");
|
||||
});
|
||||
|
||||
import { keepDesktopOverlayChrome } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("the desktop panel keeps its backdrop, Escape and focus return in every scope", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await keepDesktopOverlayChrome(page, await withWorkspace());
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const CHANGELOG_SOURCE_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
/**
|
||||
* Serves a changelog to the app instead of the repository's.
|
||||
*
|
||||
* The sheet renders whatever the document contains, so a fixture is how a test
|
||||
* states which authoring shapes it cares about.
|
||||
*/
|
||||
export async function serveChangelog(page: Page, lines: string[]): Promise<void> {
|
||||
await page.route(CHANGELOG_SOURCE_URL, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "text/plain", body: lines.join("\n") }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWhatsNew(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-changelog").click();
|
||||
|
||||
const sheet = page.getByTestId("changelog-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function release(sheet: Locator, version: string): Locator {
|
||||
return sheet.getByTestId(`changelog-release-${version}`);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
scope: '[data-testid="command-center-scope"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
|
||||
@@ -104,19 +104,20 @@ function stripAssistantMessageId(
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function stripCanonicalSubmittedPrompts(
|
||||
function stripServerFeatures(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
omittedFeatures: Set<string>,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "status" || typeof message !== "string") return message;
|
||||
if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string")
|
||||
return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { status?: unknown; features?: Record<string, unknown> } };
|
||||
payload?: { status?: unknown; features?: Record<string, unknown> };
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (payload?.status !== "server_info" || !payload.features) return message;
|
||||
delete payload.features.canonicalSubmittedPrompts;
|
||||
for (const feature of omittedFeatures) delete payload.features[feature];
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
@@ -299,7 +300,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let suppressAgentStream = false;
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripCanonicalSubmittedPromptsFeature = false;
|
||||
const omittedFeatures = new Set<string>();
|
||||
let shellToolCommandOverride: string | null = null;
|
||||
let failingTimelineAgentId: string | null = null;
|
||||
let holdingTimelineAgentId: string | null = null;
|
||||
@@ -509,11 +510,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
|
||||
outboundMessage = stripCanonicalSubmittedPrompts(
|
||||
outboundMessage,
|
||||
stripCanonicalSubmittedPromptsFeature,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type);
|
||||
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
|
||||
if (isTimelineResponse) {
|
||||
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
|
||||
@@ -823,8 +820,12 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
setAssistantMessageIdsStripped(stripped: boolean): void {
|
||||
stripAssistantMessageIds = stripped;
|
||||
},
|
||||
setWorkspaceContentSearchStripped(): void {
|
||||
omittedFeatures.add("workspaceContentSearch");
|
||||
},
|
||||
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
|
||||
stripCanonicalSubmittedPromptsFeature = stripped;
|
||||
if (stripped) omittedFeatures.add("canonicalSubmittedPrompts");
|
||||
else omittedFeatures.delete("canonicalSubmittedPrompts");
|
||||
},
|
||||
setShellToolCommandOverride(command: string | null): void {
|
||||
shellToolCommandOverride = command;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
function headerButtons(page: Page) {
|
||||
return page.getByRole("toolbar", { name: "Workspace actions", exact: true }).getByRole("button");
|
||||
}
|
||||
|
||||
async function exampleCommand(page: Page, name: string) {
|
||||
await page.setViewportSize(WIDE);
|
||||
const panel = await openCommandCenter(page);
|
||||
const title = `Button examples: ${name}`;
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = headerButtons(page),
|
||||
) {
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await info.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function closeDetails(page: Page) {
|
||||
await page.getByRole("button", { name: "Close workspace details", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function withButtonAuthorExample(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
run: (buttons: {
|
||||
refreshFromHeaderAndComposer(): Promise<void>;
|
||||
useMenusAndToggleButtons(): Promise<void>;
|
||||
inspectLiveWorkspaceDetails(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "button-example-",
|
||||
title: "Button examples",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Example agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("buttons");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
|
||||
await run({
|
||||
refreshFromHeaderAndComposer: () =>
|
||||
test.step("icon-only header action and labeled composer action update in place", async () => {
|
||||
await exampleCommand(page, "action");
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
||||
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Refresh context", exact: true }),
|
||||
).toContainText("Refreshed · 2");
|
||||
await capture(page, info, "01-actions");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await capture(page, info, "02-compact-actions");
|
||||
}),
|
||||
useMenusAndToggleButtons: () =>
|
||||
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
||||
await exampleCommand(page, "menu");
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Publish (unavailable in example)", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"03-header-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Workspace details", exact: true }).click();
|
||||
await expect(page.getByText("Button examples", { exact: true }).last()).toBeVisible();
|
||||
await closeDetails(page);
|
||||
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"04-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Hide example buttons", exact: true }).click();
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer tools", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await exampleCommand(page, "show");
|
||||
await exampleCommand(page, "disable");
|
||||
await expect(headerButtons(page)).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer tools", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, info, "05-disabled");
|
||||
await exampleCommand(page, "enable");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
).toBeInViewport();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"06-compact-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Refresh workspace", exact: true }).click();
|
||||
}),
|
||||
inspectLiveWorkspaceDetails: () =>
|
||||
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
||||
await exampleCommand(page, "popover");
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"07-header-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, "Renamed from the daemon");
|
||||
await expect(
|
||||
page.getByText("Renamed from the daemon", { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await closeDetails(page);
|
||||
await page.getByRole("button", { name: "Context details", exact: true }).click();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"08-composer-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await closeDetails(page);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Context details", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toBeInViewport();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"09-compact-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await closeDetails(page);
|
||||
await client.disablePlugin("button-examples");
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Context details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("button-examples");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const PLUGIN_ID = "button-showcase";
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
function clientSource(workspaceId: string, agentId: string): string {
|
||||
return `import React from "react";
|
||||
import { Text, View, Pressable } from "react-native";
|
||||
import { useWorkspace, useRpc } from "@getpaseo/plugin/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { summary } from "./shared/rpc";
|
||||
|
||||
function StatusIcon({ size, theme }) {
|
||||
return <View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: theme.colors.statusSuccess }} />;
|
||||
}
|
||||
|
||||
function Details({ workspaceId, theme, layout, close }) {
|
||||
const workspace = useWorkspace(workspaceId, (workspace) => workspace.name);
|
||||
const rpc = useRpc(summary);
|
||||
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
|
||||
return <View style={{ gap: 12 }}>
|
||||
<Text style={{ color: theme.colors.foreground, fontSize: 18 }}>Deployment status</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>{workspace}</Text>
|
||||
<Text style={{ color: theme.colors.statusSuccess }}>{query.data?.message ?? "Loading deployment..."}</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>3 checks passed · Production</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close deployment details" onPress={close} style={{ padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 }}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
const workspaceId = ${JSON.stringify(workspaceId)};
|
||||
const agentId = ${JSON.stringify(agentId)};
|
||||
const registrations = [];
|
||||
let finish = () => {};
|
||||
let finishReview = () => {};
|
||||
let attempts = 0;
|
||||
const action = { kind: "action", async onPress() {
|
||||
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
|
||||
await new Promise((resolve) => { finish = resolve; });
|
||||
deploy.update({ label: "Deployed" });
|
||||
} };
|
||||
const header = (id, button) => { const registration = client.addHeaderButton({ id, workspaceId, button }); registrations.push(registration); return registration; };
|
||||
const pill = (id, button) => { const registration = client.addComposerPill({ id, workspaceId, agentId, button }); registrations.push(registration); return registration; };
|
||||
const menu = { kind: "menu", items: [
|
||||
{ kind: "item", id: "run", title: "Run checks", icon: "Play", behavior: { kind: "action", onPress() { checks.update({ label: "Checks passed" }); } } },
|
||||
{ kind: "separator", id: "divider" },
|
||||
{ kind: "item", id: "environment", title: "Environment", behavior: { kind: "menu", items: [
|
||||
{ kind: "item", id: "staging", title: "Staging", icon: "FlaskConical", behavior: { kind: "action", onPress() {} } },
|
||||
{ kind: "item", id: "production", title: "Production", icon: "Globe", disabled: true, behavior: { kind: "action", onPress() {} } },
|
||||
] } },
|
||||
{ kind: "item", id: "details", title: "Deployment details", icon: "Activity", behavior: { kind: "popover", Content: Details } },
|
||||
] };
|
||||
const deploy = header("deploy", { title: "Deploy application", icon: "Rocket", label: "Deploy", behavior: action });
|
||||
const checks = header("checks", { title: "Header checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
header("status", { title: "Header status", icon: StatusIcon, label: "Healthy", behavior: { kind: "popover", Content: Details } });
|
||||
header("logs", { title: "Open deployment logs", icon: "ScrollText", behavior: { kind: "popover", Content: Details } });
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
command("hide-deploy", "Hide deployment button", () => deploy.update({ visible: false }));
|
||||
command("show-deploy", "Show deployment button", () => deploy.update({ visible: true }));
|
||||
command("hide", "Hide review button", () => review.update({ visible: false }));
|
||||
command("show", "Show review button", () => review.update({ visible: true, label: "Review ready" }));
|
||||
command("disable", "Disable example buttons", () => { deploy.update({ disabled: true }); review.update({ disabled: true }); });
|
||||
command("enable", "Enable example buttons", () => { deploy.update({ disabled: false }); review.update({ disabled: false }); });
|
||||
command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined }));
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => {
|
||||
if (update.kind !== "upsert") return;
|
||||
deploy.update({ visible: update.workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" });
|
||||
});
|
||||
return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
|
||||
}`;
|
||||
}
|
||||
|
||||
async function installShowcase(workspaceId: string, agentId: string) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-buttons-"));
|
||||
await mkdir(path.join(directory, "shared"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), clientSource(workspaceId, agentId));
|
||||
await writeFile(
|
||||
path.join(directory, "shared/rpc.ts"),
|
||||
`import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const summary = defineRpc({ name: "summary", input: z.object({}), output: z.object({ message: z.string() }) });`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`import { summary } from "./shared/rpc"; export default function contribute(server) { server.handle(summary, async () => ({ message: "All systems operational" })); return () => {}; }`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
disable: () => client.disablePlugin(PLUGIN_ID),
|
||||
setTitle: (title: string) => client.setWorkspaceTitle(workspaceId, title),
|
||||
async cleanup() {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function press(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function openHeaderOverflow(page: Page) {
|
||||
await page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function choose(page: Page, title: string) {
|
||||
await page.getByRole("menuitem", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function command(page: Page, title: string) {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
) {
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectDetails(page: Page) {
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
await expect(title).toHaveCSS("text-overflow", "ellipsis");
|
||||
await expect(subtitle).toHaveCSS("text-overflow", "ellipsis");
|
||||
// The text itself must overflow its box, rather than being clipped by an ancestor.
|
||||
await expect
|
||||
.poll(() => title.evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => subtitle.evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true);
|
||||
const titleBox = await title.boundingBox();
|
||||
const subtitleBox = await subtitle.boundingBox();
|
||||
const actionsBox = await page
|
||||
.getByRole("button", { name: "Workspace actions", exact: true })
|
||||
.boundingBox();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(subtitleBox).not.toBeNull();
|
||||
expect(actionsBox).not.toBeNull();
|
||||
expect(titleBox!.x + titleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
|
||||
expect(subtitleBox!.x + subtitleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
|
||||
const hostIcon = page.getByLabel("localhost", { exact: true }).locator("svg");
|
||||
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
|
||||
}
|
||||
|
||||
async function holdButton(page: Page, button: Locator) {
|
||||
await button.hover();
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
|
||||
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
const action = page.getByRole("button", { name: "Deploy application", exact: true });
|
||||
const overflow = page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true });
|
||||
await expect(action).toHaveCSS("border-top-width", "0px");
|
||||
await expect(overflow).toHaveCSS("border-top-width", "0px");
|
||||
await hamburger.hover();
|
||||
const highlight = await hamburger.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await action.hover();
|
||||
await expect(action).toHaveCSS("background-color", highlight);
|
||||
await holdButton(page, overflow);
|
||||
await expect(overflow).toHaveCSS("background-color", highlight);
|
||||
await capture(page, testInfo, "12c-compact-overflow-pressed");
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function withButtonShowcase(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" });
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Button showcase",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const showcase = await installShowcase(workspace.workspaceId, agent.id);
|
||||
try {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible();
|
||||
|
||||
await run({
|
||||
openWideMenusAndPopovers: () =>
|
||||
test.step("wide buttons have placement-owned labels and chevrons", async () => {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-wide-buttons");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"02-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Environment");
|
||||
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"03-header-submenu",
|
||||
page.getByRole("menuitem", { name: "Staging", exact: true }),
|
||||
);
|
||||
await choose(page, "Staging");
|
||||
await openHeaderOverflow(page);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"03b-wide-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Open deployment logs");
|
||||
await expectDetails(page);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"04-header-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"05-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"06-composer-menu-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"07-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
}),
|
||||
runAndUpdateActions: () =>
|
||||
test.step("actions fail visibly, retry, and remain busy until completion", async () => {
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"08-action-error",
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
);
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09-action-pending");
|
||||
await command(page, "Finish deployment");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toContainText("Deployed");
|
||||
await press(page, "Run review");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09b-composer-pending");
|
||||
await command(page, "Finish review");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Reviewed",
|
||||
);
|
||||
await command(page, "Disable example buttons");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "10-disabled-buttons");
|
||||
await command(page, "Enable example buttons");
|
||||
await command(page, "Use icon-only header");
|
||||
await capture(page, testInfo, "11-icon-only-header");
|
||||
await command(page, "Hide review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await command(page, "Show review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
await workspace.client.renameProject(
|
||||
workspace.projectId,
|
||||
"Plugin buttons with a long project name",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(
|
||||
"Plugin buttons with a long project name",
|
||||
);
|
||||
await showcase.setTitle("Button showcase with a long workspace title");
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Button showcase with a long workspace title",
|
||||
);
|
||||
await expectHeaderTextTruncated(page);
|
||||
await capture(page, testInfo, "12-compact-buttons");
|
||||
await openCompactOverflowWithHeaderChrome(page, testInfo);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"13-compact-header-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Header checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"14-compact-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"15-compact-header-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await showcase.setTitle("Compact checks");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"15b-compact-header-direct-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
await showcase.setTitle("Button showcase");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"16-compact-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"17-compact-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
}),
|
||||
hideAndUnloadOpenButtons: () =>
|
||||
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await press(page, "Composer checks");
|
||||
await showcase.setTitle("Hide composer menu");
|
||||
await expect(page.getByRole("menuitem", { name: "Run checks", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await showcase.setTitle("Button showcase");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await capture(page, testInfo, "18-dark-buttons");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"19-dark-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await showcase.disable();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Header status", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await showcase.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openModelPicker } from "./agent-profiles";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
|
||||
const MODEL_LABEL = "Select model (Example 1)";
|
||||
const WIDE = { width: 1400, height: 950 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
async function readIconPaths(page: Page, pluginDirectory: string): Promise<string[]> {
|
||||
const svg = await readFile(path.join(pluginDirectory, "icon.svg"), "utf8");
|
||||
return page.evaluate(
|
||||
(source) =>
|
||||
Array.from(
|
||||
new DOMParser().parseFromString(source, "image/svg+xml").querySelectorAll("path"),
|
||||
(element) => element.getAttribute("d") ?? "",
|
||||
),
|
||||
svg,
|
||||
);
|
||||
}
|
||||
|
||||
function readIconDrawings(icons: SVGElement[]): string[][] {
|
||||
return icons.map((icon) =>
|
||||
Array.from(icon.querySelectorAll("path"), (element) => element.getAttribute("d") ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectProviderIcon(surface: Locator, paths: string[]): Promise<void> {
|
||||
await expect(surface).toBeVisible();
|
||||
// Provider icons are decorative SVGs without accessible names. Compare the
|
||||
// plugin asset's drawing, allowing surface-specific size and theme colours.
|
||||
await expect
|
||||
.poll(() => surface.locator("svg").evaluateAll(readIconDrawings))
|
||||
.toContainEqual(paths);
|
||||
}
|
||||
|
||||
async function selectPluginModel(page: Page): Promise<void> {
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByText("Direct provider example", { exact: true }).click();
|
||||
await page.getByText("Example 1", { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: MODEL_LABEL, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
interface ProviderIconJourney {
|
||||
page: Page;
|
||||
testInfo: TestInfo;
|
||||
iconPaths: string[];
|
||||
workspace: SeededWorkspace;
|
||||
}
|
||||
|
||||
export const test = base.extend<{ providerIcons: ProviderIconJourney }>({
|
||||
providerIcons: async ({ page }, provide, testInfo) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-provider-icons-" });
|
||||
try {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const iconPaths = await readIconPaths(page, plugin.directory);
|
||||
await provide({ page, testInfo, iconPaths, workspace });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin("provider-direct-example");
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await plugin.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function verifyProviderSettings({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("provider settings render the registered icon", async () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "provider-settings");
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyNewWorkspaceModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("new workspace keeps the picker icon on its selected model button", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectPluginModel(page);
|
||||
await openModelPicker(page);
|
||||
await expectProviderIcon(page.getByTestId("model-row-direct-example-example-1"), iconPaths);
|
||||
await capture(page, testInfo, "new-workspace-picker");
|
||||
await page.keyboard.press("Escape");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "new-workspace-selected-model");
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCompactModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("compact composer preserves the same provider icon", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "compact-selected-model");
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyExistingAgentModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
workspace,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("existing agent composer uses the same icon", async () => {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "direct-example",
|
||||
model: "example-1",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Plugin icon agent",
|
||||
});
|
||||
await page.setViewportSize(WIDE);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "agent-selected-model");
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export interface SeedDaemonClient {
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
||||
setWorkspaceLabel(input: {
|
||||
workspaceId: string;
|
||||
label: { name: string; color: "red" };
|
||||
@@ -159,6 +160,7 @@ export interface SeedDaemonClient {
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
workspaceMarkUnread?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { composerLocator } from "./composer";
|
||||
import type { CreatedWorkspace } from "./with-workspace";
|
||||
|
||||
export async function prepareContentSearch(workspace: CreatedWorkspace) {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n',
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
}
|
||||
export async function searchContents(page: Page, query: string) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
await expect(input).toBeFocused();
|
||||
// The query field is the Command Center's own header input, above the results and preview.
|
||||
const inputBox = await input.boundingBox();
|
||||
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
|
||||
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
|
||||
await panel.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
// The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump.
|
||||
await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function openCompactPreview(page: Page, screenshot: string) {
|
||||
await page.getByRole("button", { name: /search-a.ts:1:26/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("export const greeting");
|
||||
await page.getByTestId("command-center-panel").screenshot({ path: screenshot });
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
}
|
||||
|
||||
export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) {
|
||||
const source = page.getByTestId("file-source-editor").locator(".cm-content");
|
||||
await source.fill("const localUnsaved = 42;\n");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n// changed on disk\n',
|
||||
);
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await searchContents(page, "needle");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("changed on disk");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(source).toContainText("localUnsaved");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await source.press("Control+z");
|
||||
await expect(source).toContainText('const emoji = "é🙂 needle needle";');
|
||||
await expect(source).not.toContainText("localUnsaved");
|
||||
}
|
||||
|
||||
export async function recoverChangedAndMissingPreview(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
screenshot: string,
|
||||
) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByRole("status")).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await unlink(path.join(workspace.repoPath, "search-a.ts"));
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE");
|
||||
await input.fill("not-present-anywhere");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250));
|
||||
await input.fill("needle");
|
||||
await expect(panel.getByText(/Results limited/)).toBeVisible();
|
||||
await panel.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function searchContentsByTouch(page: Page, query: string) {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
})
|
||||
.fill("Search file contents");
|
||||
await page.getByText("Search file contents", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openLiteralSourceOccurrence(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean },
|
||||
screenshot: string,
|
||||
) {
|
||||
await writeFile(path.join(workspace.repoPath, input.path), input.content);
|
||||
await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file");
|
||||
await workspace.navigateTo();
|
||||
if (input.imageFirst) {
|
||||
await page.keyboard.press("Meta+p");
|
||||
await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path);
|
||||
await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click();
|
||||
await expect(page.getByTestId("image-file-preview")).toBeVisible();
|
||||
}
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("needle");
|
||||
await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("needle");
|
||||
await expect(source).not.toContainText("different file");
|
||||
await expect(source.locator(".cm-selectionBackground")).toBeVisible();
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) {
|
||||
// rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the
|
||||
// whole workspace was read.
|
||||
await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`);
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await input.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
panel.getByText("Files over 1.0 MB were not searched", { exact: true }),
|
||||
).toBeVisible();
|
||||
// The occurrence-cap notice is a different claim and must not appear here.
|
||||
await expect(panel.getByText(/Results limited/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) {
|
||||
// A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt".
|
||||
await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE");
|
||||
await mkdir(path.join(workspace.repoPath, "a"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE");
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: /a\\b\.txt:1:1/ })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("LITERAL FILE");
|
||||
await expect(source).not.toContainText("THIS IS THE OTHER FILE");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening the same occurrence twice has to navigate the pane both times. The tab target is
|
||||
* deliberately identity-stable, so the second activation looks like a no-op unless it asks the
|
||||
* file-opening owner to navigate again.
|
||||
*/
|
||||
export async function reopenSameOccurrenceAfterMovingAway(page: Page, workspace: CreatedWorkspace) {
|
||||
const lines = Array.from(
|
||||
{ length: 1200 },
|
||||
(_value, index) => `export const filler${index} = ${index};`,
|
||||
);
|
||||
lines.push('const marker = "REOPEN_TARGET";');
|
||||
await writeFile(path.join(workspace.repoPath, "reopen.ts"), `${lines.join("\n")}\n`);
|
||||
await workspace.navigateTo();
|
||||
|
||||
const scroller = page.getByTestId("file-source-editor").locator(".cm-scroller");
|
||||
const offset = () => scroller.evaluate((element) => element.scrollTop);
|
||||
|
||||
await openReopenTarget(page);
|
||||
const revealed = await offset();
|
||||
expect(revealed).toBeGreaterThan(0);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
|
||||
).toBeInViewport();
|
||||
|
||||
// Move the caret to the top of the same file, inside the same editor instance.
|
||||
await page.getByTestId("file-source-editor").locator(".cm-content").click();
|
||||
await page.keyboard.press("ControlOrMeta+Home");
|
||||
await expect.poll(offset).toBeLessThan(revealed / 2);
|
||||
|
||||
await openReopenTarget(page);
|
||||
await expect
|
||||
.poll(offset, { message: "reopening the same occurrence must navigate the pane again" })
|
||||
.toBeGreaterThan(revealed / 2);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
|
||||
).toBeInViewport();
|
||||
// The editor instance is reused, so its history survives the second navigation.
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("REOPEN_TARGET");
|
||||
}
|
||||
|
||||
async function openReopenTarget(page: Page) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("REOPEN_TARGET");
|
||||
await expect(panel.getByRole("button", { name: /reopen\.ts:1201:/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two files can share a base name and their nearest parent, and a real path is often wider than
|
||||
* the result column, so the exact workspace-relative path lives in the tooltip. Being in the DOM
|
||||
* is not enough: the tooltip has to be the thing painted at its own coordinates.
|
||||
*/
|
||||
export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) {
|
||||
const deep =
|
||||
"packages/app/src/command-center/workspace-content-search/internal/deeply/nested/result-path-presentation.ts";
|
||||
for (const relative of [
|
||||
"packages/app/src/utils/index.ts",
|
||||
"packages/server/src/utils/index.ts",
|
||||
deep,
|
||||
]) {
|
||||
const target = path.join(workspace.repoPath, relative);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, `export const shared = "SHARED_UTIL";\n`);
|
||||
}
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("SHARED_UTIL");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const rows = panel.getByRole("button", { name: /:1:24/ });
|
||||
await expect(rows).toHaveCount(3);
|
||||
const tip = page.getByTestId("content-search-row-path");
|
||||
|
||||
async function expectPaintedPath(row: Locator, expected: string) {
|
||||
await row.hover();
|
||||
await expect(tip).toHaveText(expected);
|
||||
// The reader must actually see it: whatever is painted at the tooltip's own coordinates has
|
||||
// to be the tooltip, not the panel that used to cover it.
|
||||
// A tooltip is pointer-transparent, so elementFromPoint reports the row underneath either
|
||||
// way, and its surface is the same white as the panel. What settles it is whether the pixels
|
||||
// in its own rectangle change when it opens: if the panel still covers it, they do not.
|
||||
const box = await tip.boundingBox();
|
||||
if (!box) throw new Error("tooltip has no box");
|
||||
const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
const shown = await page.screenshot({ clip });
|
||||
await page.mouse.move(2, 2);
|
||||
await expect(tip).toBeHidden();
|
||||
const hidden = await page.screenshot({ clip });
|
||||
expect(
|
||||
shown.equals(hidden),
|
||||
"the tooltip's own rectangle must change when it opens, or the reader cannot see it",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Same base name and same nearest parent: the resting labels cannot tell these apart.
|
||||
const app = rows.filter({ hasText: "…/utils/index.ts:1:24" }).first();
|
||||
const server = rows.filter({ hasText: "…/utils/index.ts:1:24" }).last();
|
||||
await expect(app).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /);
|
||||
await expect(server).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /);
|
||||
await expectPaintedPath(app, "packages/app/src/utils/index.ts:1:24");
|
||||
await expectPaintedPath(server, "packages/server/src/utils/index.ts:1:24");
|
||||
|
||||
// A path far wider than the result column still reads in full.
|
||||
await expectPaintedPath(
|
||||
rows.filter({ hasText: "result-path-presentation.ts" }).first(),
|
||||
`${deep}:1:24`,
|
||||
);
|
||||
return { panel, rows, tip };
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay chrome the desktop panel owns: a backdrop that dismisses it, Escape that dismisses
|
||||
* it, and focus that goes back where it came from. Hosting the panel in the shared overlay root
|
||||
* rather than a native Modal must not change any of it.
|
||||
*/
|
||||
export async function keepDesktopOverlayChrome(page: Page, workspace: CreatedWorkspace) {
|
||||
await writeFile(path.join(workspace.repoPath, "chrome.ts"), 'const a = "CHROME_TARGET";\n');
|
||||
await workspace.navigateTo();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
|
||||
// Backdrop dismisses the panel.
|
||||
const composer = composerLocator(page);
|
||||
await composer.click();
|
||||
await expect(composer).toBeFocused();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(input).toBeFocused();
|
||||
await page.mouse.click(page.viewportSize()!.width - 12, page.viewportSize()!.height - 12);
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
// Escape dismisses it and hands focus back to the composer it was opened from.
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await input.fill("CHROME_TARGET");
|
||||
await expect(panel.getByRole("button", { name: /chrome\.ts:1:/ })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(composer).toBeFocused();
|
||||
|
||||
// Ordinary commands and the Files scope still open over the same chrome.
|
||||
await page.keyboard.press("Meta+k");
|
||||
await expect(page.getByTestId("command-center-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { ChangelogHost } from "@/changelog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
@@ -619,6 +620,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<ChangelogHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
import { ChangelogSheet } from "./internal/changelog-sheet";
|
||||
|
||||
/** Mounted once by the app shell so any surface can call `openChangelog()`. */
|
||||
export function ChangelogHost() {
|
||||
const visible = useChangelogStore((state) => state.visible);
|
||||
const close = useChangelogStore((state) => state.close);
|
||||
|
||||
return <ChangelogSheet visible={visible} onClose={close} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* What's new: the release notes, read from the repository's CHANGELOG.md and
|
||||
* rendered in the app instead of sending the reader to a browser.
|
||||
*
|
||||
* Two things leave this module — the host the app shell mounts, and the command
|
||||
* every entry point calls. Where the document comes from, how it is parsed, and
|
||||
* how much of it renders at once stay inside; import from `@/changelog`, never
|
||||
* a path within it.
|
||||
*/
|
||||
export { ChangelogHost } from "./changelog-host";
|
||||
export { openChangelog } from "./open-changelog";
|
||||
@@ -0,0 +1,241 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ExternalLink, Gift } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useChangelog, type ChangelogState } from "./changelog-source";
|
||||
import { useRevealedReleases } from "./use-revealed-releases";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
iconButtonChromeStyle({ size: "large", state });
|
||||
|
||||
interface ChangelogSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, reload } = useChangelog(visible);
|
||||
const { count, showMore } = useRevealedReleases(visible && state.status === "ready");
|
||||
|
||||
const handleOpenWebsite = useCallback(() => {
|
||||
void openExternalUrl(WEBSITE_CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
const header: SheetHeader = useMemo(
|
||||
() => ({
|
||||
title: t("changelog.title"),
|
||||
leading: sheetLeadingIcon,
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleOpenWebsite}
|
||||
hitSlop={8}
|
||||
style={websiteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("changelog.openWebsite")}
|
||||
testID="changelog-open-website"
|
||||
>
|
||||
<ThemedExternalLink
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
uniProps={mutedColorMapping}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
}),
|
||||
[handleOpenWebsite, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
desktopMaxWidth={620}
|
||||
desktopHeight="85%"
|
||||
testID="changelog-sheet"
|
||||
>
|
||||
<ChangelogBody state={state} shownReleases={count} onShowMore={showMore} onRetry={reload} />
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const SNAP_POINTS = ["85%", "95%"];
|
||||
|
||||
interface ChangelogBodyProps {
|
||||
state: ChangelogState;
|
||||
shownReleases: number;
|
||||
onShowMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const appVersion = useMemo(() => resolveAppVersion()?.replace(/^v/i, "") ?? null, []);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Alert
|
||||
variant="error"
|
||||
title={t("changelog.error.title")}
|
||||
description={t("changelog.error.description")}
|
||||
testID="changelog-error"
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleReleases = state.releases.slice(0, shownReleases);
|
||||
|
||||
return (
|
||||
<View style={styles.releaseList}>
|
||||
{visibleReleases.map((release) => (
|
||||
<ReleaseView
|
||||
key={`${release.version}:${release.date}`}
|
||||
release={release}
|
||||
isCurrent={release.version === appVersion}
|
||||
/>
|
||||
))}
|
||||
{state.releases.length > visibleReleases.length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={onShowMore}
|
||||
style={styles.showMore}
|
||||
testID="changelog-show-more"
|
||||
>
|
||||
{t("changelog.showMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReleaseViewProps {
|
||||
release: ChangelogRelease;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const date = formatChangelogDate(release.date);
|
||||
|
||||
return (
|
||||
<View style={styles.release} testID={`changelog-release-${release.version}`}>
|
||||
<View style={styles.releaseHeading}>
|
||||
<Text style={styles.version}>{release.version}</Text>
|
||||
{isCurrent ? <StatusBadge label={t("changelog.installed")} /> : null}
|
||||
<View style={styles.headingSpacer} />
|
||||
{date ? <Text style={styles.date}>{date}</Text> : null}
|
||||
</View>
|
||||
{keyChangelogSections(release.sections).map(({ key, section }) => (
|
||||
<View key={key} style={styles.section}>
|
||||
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
|
||||
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** A release can repeat a section title; the occurrence keeps the key stable. */
|
||||
function keyChangelogSections(
|
||||
sections: readonly ChangelogSection[],
|
||||
): { key: string; section: ChangelogSection }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return sections.map((section) => {
|
||||
const title = section.title ?? "";
|
||||
const occurrence = seen.get(title) ?? 0;
|
||||
seen.set(title, occurrence + 1);
|
||||
return { key: `${title}:${occurrence}`, section };
|
||||
});
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
minHeight: 160,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
releaseList: {
|
||||
gap: theme.spacing[8],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
release: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
releaseHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headingSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
version: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
showMore: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { parseChangelog, type ChangelogRelease } from "./parse-changelog";
|
||||
|
||||
const CHANGELOG_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
export type ChangelogState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; releases: ChangelogRelease[] }
|
||||
| { status: "error" };
|
||||
|
||||
// Survives close/reopen so the second look paints without a spinner. The raw
|
||||
// text is kept alongside the releases so an unchanged revalidation can be
|
||||
// dropped: handing back an equal-but-new array would re-render every release.
|
||||
let cached: { markdown: string; releases: ChangelogRelease[] } | null = null;
|
||||
|
||||
export interface Changelog {
|
||||
state: ChangelogState;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the changelog from the repository the app was built from.
|
||||
*
|
||||
* The daemon is not involved: the changelog describes the app, a phone reaching
|
||||
* a relay already has internet, and going through a host would make the notes
|
||||
* depend on which host happens to be connected.
|
||||
*
|
||||
* Every open refetches, because the whole point of opening it is a release that
|
||||
* shipped after this app started. A previous result stays on screen while that
|
||||
* happens, and survives a failed revalidation.
|
||||
*/
|
||||
export function useChangelog(enabled: boolean): Changelog {
|
||||
const [state, setState] = useState<ChangelogState>(readCache);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setState(readCache());
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(CHANGELOG_URL, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Changelog request failed: ${response.status}`);
|
||||
const markdown = await response.text();
|
||||
if (cached?.markdown === markdown) return;
|
||||
const releases = parseChangelog(markdown);
|
||||
if (releases.length === 0) throw new Error("Changelog has no releases");
|
||||
cached = { markdown, releases };
|
||||
setState({ status: "ready", releases });
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cached) return;
|
||||
setState({ status: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [enabled, attempt]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
cached = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
return { state, reload };
|
||||
}
|
||||
|
||||
function readCache(): ChangelogState {
|
||||
return cached ? { status: "ready", releases: cached.releases } : { status: "loading" };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ChangelogVisibility {
|
||||
visible: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useChangelogStore = create<ChangelogVisibility>((set) => ({
|
||||
visible: false,
|
||||
open: () => set({ visible: true }),
|
||||
close: () => set({ visible: false }),
|
||||
}));
|
||||
@@ -0,0 +1,301 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
parseChangelog,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
function bodies(markdown: string, releaseIndex = 0): string[] {
|
||||
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
|
||||
}
|
||||
|
||||
function sectionBody(section: ChangelogSection): string {
|
||||
return section.body;
|
||||
}
|
||||
|
||||
function sectionTitle(section: ChangelogSection): string | null {
|
||||
return section.title;
|
||||
}
|
||||
|
||||
function releaseDate(release: ChangelogRelease): string {
|
||||
return release.date;
|
||||
}
|
||||
|
||||
function authoredLines(release: ChangelogRelease): string[] {
|
||||
return release.sections.flatMap(sectionLines);
|
||||
}
|
||||
|
||||
function sectionLines(section: ChangelogSection): string[] {
|
||||
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
|
||||
}
|
||||
|
||||
function isNotBlank(line: string): boolean {
|
||||
return line.trim().length > 0;
|
||||
}
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("reads the shape the changelog is authored in today", () => {
|
||||
const releases = parseChangelog(
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 0.8.0-beta.1 - 2026-09-08",
|
||||
"",
|
||||
"This release extends what plugins can do.",
|
||||
"",
|
||||
"### Plugins",
|
||||
"",
|
||||
"- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed the mobile terminal keyboard closing",
|
||||
"",
|
||||
"## 0.7.2 - 2026-09-02",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a thing",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(releases).toEqual([
|
||||
{
|
||||
version: "0.8.0-beta.1",
|
||||
date: "2026-09-08",
|
||||
sections: [
|
||||
{ title: null, body: "This release extends what plugins can do." },
|
||||
{
|
||||
title: "Plugins",
|
||||
body: "- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
},
|
||||
{ title: "Fixed", body: "- Fixed the mobile terminal keyboard closing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.2",
|
||||
date: "2026-09-02",
|
||||
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops everything above the first release heading", () => {
|
||||
const releases = parseChangelog(
|
||||
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
|
||||
});
|
||||
|
||||
it("returns nothing for a document with no releases", () => {
|
||||
expect(parseChangelog("")).toEqual([]);
|
||||
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("release headings", () => {
|
||||
const headings: [string, { version: string; date: string }][] = [
|
||||
["## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3", { version: "1.2.3", date: "" }],
|
||||
["## [1.2.3] - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## v1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 — 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 – 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 (2026-01-01)", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - January 1, 2026", { version: "1.2.3", date: "January 1, 2026" }],
|
||||
[
|
||||
"## [1.2.3](https://example.com/v1.2.3) - 2026-01-01",
|
||||
{ version: "1.2.3", date: "2026-01-01" },
|
||||
],
|
||||
["## Unreleased", { version: "Unreleased", date: "" }],
|
||||
["## [Unreleased]", { version: "Unreleased", date: "" }],
|
||||
["## 1.2.3 - 2026-01-01 ", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - 2026-01-01 ##", { version: "1.2.3", date: "2026-01-01" }],
|
||||
[" ## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 0.8.0-beta.1 - 2026-09-08", { version: "0.8.0-beta.1", date: "2026-09-08" }],
|
||||
];
|
||||
|
||||
for (const [heading, expected] of headings) {
|
||||
it(`reads ${JSON.stringify(heading)}`, () => {
|
||||
const [release] = parseChangelog(`${heading}\n\n- Entry\n`);
|
||||
expect({ version: release.version, date: release.date }).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps both entries when a version appears twice", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-02\n\n- Two\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases.map(releaseDate)).toEqual(["2026-01-02", "2026-01-01"]);
|
||||
});
|
||||
|
||||
it("does not treat a deeper heading as a release", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
|
||||
});
|
||||
|
||||
it("does not treat an unspaced hash run as a heading", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections[0].body).toBe("##notaheading");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sections", () => {
|
||||
it("names sections from the document instead of a fixed list", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-01\n\n### Security\n\n- One\n\n### Deprecated\n\n- Two\n",
|
||||
);
|
||||
expect(releases[0].sections.map(sectionTitle)).toEqual(["Security", "Deprecated"]);
|
||||
});
|
||||
|
||||
it("keeps a section that has a heading but no body", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n### Added\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([
|
||||
{ title: "Fixed", body: "" },
|
||||
{ title: "Added", body: "- One" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an empty lead section", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
|
||||
});
|
||||
|
||||
it("keeps a release that is only prose", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block kinds it does not interpret", () => {
|
||||
it("passes a block quote, callout, table and embed through untouched", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Notes",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> This release drops macOS 12.",
|
||||
"",
|
||||
"| Platform | Status |",
|
||||
"| --- | --- |",
|
||||
"| macOS | Shipped |",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
'<video src="https://example.com/demo.mp4" controls></video>',
|
||||
"",
|
||||
'<iframe src="https://example.com/embed"></iframe>',
|
||||
"",
|
||||
"<details><summary>More</summary>Hidden</details>",
|
||||
].join("\n");
|
||||
|
||||
expect(bodies(source)).toEqual([source.split("\n").slice(4).join("\n")]);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fenced code block", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"```md",
|
||||
"## 9.9.9 - 2099-01-01",
|
||||
"### Fake section",
|
||||
"```",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
const releases = parseChangelog(source);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([
|
||||
{
|
||||
title: "Added",
|
||||
body: "```md\n## 9.9.9 - 2099-01-01\n### Fake section\n```\n\n- Real entry",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks tilde fences and longer fence runs", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"~~~~",
|
||||
"## 9.9.9",
|
||||
"```",
|
||||
"~~~~",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fence nested in a list item", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"- Entry with a sample",
|
||||
"",
|
||||
" ```",
|
||||
" ## 9.9.9",
|
||||
" ```",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("reads CRLF documents", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
|
||||
expect(releases).toEqual([
|
||||
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChangelogDate", () => {
|
||||
it("localizes an ISO date without shifting it across a timezone", () => {
|
||||
expect(formatChangelogDate("2026-09-08")).toBe("September 8, 2026");
|
||||
});
|
||||
|
||||
it("shows anything else as authored", () => {
|
||||
expect(formatChangelogDate("September 8, 2026")).toBe("September 8, 2026");
|
||||
expect(formatChangelogDate("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the repository's own CHANGELOG.md", () => {
|
||||
const markdown = readFileSync(
|
||||
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const releases = parseChangelog(markdown);
|
||||
|
||||
it("parses every release heading", () => {
|
||||
expect(releases.length).toBeGreaterThan(50);
|
||||
for (const release of releases) {
|
||||
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(release.sections.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every authored line inside a release", () => {
|
||||
const documentLines = markdown.split("\n");
|
||||
const fromFirstRelease = documentLines.slice(
|
||||
documentLines.findIndex((line) => line.startsWith("## ")),
|
||||
);
|
||||
|
||||
expect(releases.flatMap(authoredLines).filter(isNotBlank)).toEqual(
|
||||
fromFirstRelease.filter(isNotBlank).filter((line) => !line.startsWith("## ")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Structural parser for a CHANGELOG.md document.
|
||||
*
|
||||
* The document is authored in the repository, fetched at runtime, and rewritten
|
||||
* on every release, so this parser commits to exactly two facts: a `##` heading
|
||||
* starts a release and a `###` heading starts a section inside it. Everything
|
||||
* else — prose, callouts, block quotes, fenced code, images, video and embed
|
||||
* HTML, tables, nested lists — is carried through verbatim and handed to the
|
||||
* Markdown renderer. A new block kind in the changelog therefore needs no
|
||||
* change here.
|
||||
*
|
||||
* Section titles are read, never matched: renaming "Improved" or adding a
|
||||
* section is a content change, not a code change.
|
||||
*
|
||||
* The one way a line scan can be fooled is a fenced block whose contents look
|
||||
* like a heading, so fences are tracked.
|
||||
*/
|
||||
|
||||
export interface ChangelogSection {
|
||||
/** The `###` heading text, or null for content that precedes the first one. */
|
||||
title: string | null;
|
||||
/** Verbatim markdown under the heading. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
/** Version as authored, stripped of link, bracket and `v` decoration. */
|
||||
version: string;
|
||||
/** The heading tail after the version. Empty when the heading carried none. */
|
||||
date: string;
|
||||
/** Sections in document order; the untitled lead section comes first. */
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
||||
// `##` / `###` must be followed by whitespace, which is what keeps each pattern
|
||||
// from matching one level deeper.
|
||||
const RELEASE_HEADING = /^ {0,3}##(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const SECTION_HEADING = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const FENCE_OPEN = /^\s*(`{3,}|~{3,})/;
|
||||
const ATX_CLOSING = /[ \t]+#+[ \t]*$/;
|
||||
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
||||
const DASH_SEPARATED_DATE = /^(.*?)\s+[-–—]\s+(.+)$/;
|
||||
const PARENTHESIZED_DATE = /^(.*?)\s*\((.+)\)$/;
|
||||
const BRACKETED_VERSION = /^\[(.*)\]$/;
|
||||
const LEADING_V = /^v(?=\d)/i;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface SectionDraft {
|
||||
title: string | null;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface ReleaseDraft {
|
||||
version: string;
|
||||
date: string;
|
||||
sections: SectionDraft[];
|
||||
}
|
||||
|
||||
export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
const releases: ReleaseDraft[] = [];
|
||||
let fence: string | null = null;
|
||||
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const fenceEdge = line.match(FENCE_OPEN)?.[1];
|
||||
if (fenceEdge) {
|
||||
if (!fence) {
|
||||
fence = fenceEdge;
|
||||
} else if (fenceEdge[0] === fence[0] && fenceEdge.length >= fence.length) {
|
||||
fence = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fence) {
|
||||
const releaseHeading = line.match(RELEASE_HEADING);
|
||||
if (releaseHeading) {
|
||||
releases.push({
|
||||
...splitReleaseHeading(releaseHeading[1] ?? ""),
|
||||
sections: [{ title: null, lines: [] }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionHeading = line.match(SECTION_HEADING);
|
||||
if (sectionHeading && releases.length > 0) {
|
||||
releases[releases.length - 1].sections.push({
|
||||
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
|
||||
lines: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const release = releases.at(-1);
|
||||
if (release) release.sections[release.sections.length - 1].lines.push(line);
|
||||
}
|
||||
|
||||
return releases.map(materializeRelease);
|
||||
}
|
||||
|
||||
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
|
||||
const sections = draft.sections
|
||||
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
|
||||
// A heading with an empty body still names something; an empty lead section
|
||||
// is just the gap between the release heading and the first section.
|
||||
.filter((section) => section.title !== null || section.body.length > 0);
|
||||
return { version: draft.version, date: draft.date, sections };
|
||||
}
|
||||
|
||||
function trimBlankLines(lines: string[]): string {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && lines[start].trim().length === 0) start += 1;
|
||||
while (end > start && lines[end - 1].trim().length === 0) end -= 1;
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
function splitReleaseHeading(heading: string): { version: string; date: string } {
|
||||
const plain = heading.replace(ATX_CLOSING, "").replace(MARKDOWN_LINK, "$1").trim();
|
||||
|
||||
const dashed = plain.match(DASH_SEPARATED_DATE);
|
||||
if (dashed) return { version: normalizeVersion(dashed[1]), date: dashed[2].trim() };
|
||||
|
||||
const parenthesized = plain.match(PARENTHESIZED_DATE);
|
||||
if (parenthesized) {
|
||||
return { version: normalizeVersion(parenthesized[1]), date: parenthesized[2].trim() };
|
||||
}
|
||||
|
||||
return { version: normalizeVersion(plain), date: "" };
|
||||
}
|
||||
|
||||
function normalizeVersion(raw: string): string {
|
||||
const unbracketed = raw.trim().replace(BRACKETED_VERSION, "$1").trim();
|
||||
return unbracketed.replace(LEADING_V, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO dates become the reader's locale; anything else the author wrote is shown
|
||||
* as authored rather than guessed at.
|
||||
*/
|
||||
export function formatChangelogDate(date: string): string {
|
||||
if (!ISO_DATE.test(date)) return date;
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
const value = new Date(Date.UTC(year, month - 1, day));
|
||||
if (Number.isNaN(value.getTime())) return date;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* How much of the changelog is on screen.
|
||||
*
|
||||
* The latest release renders as soon as there is one, because that is what
|
||||
* nearly every open is asking for. The rest of the first page follows
|
||||
* immediately, and every Show more after it, as transitions: React then slices
|
||||
* that work around the opening animation instead of committing hundreds of list
|
||||
* items in one task. The whole document is well over a thousand of them.
|
||||
*
|
||||
* The input is "releases are on screen", not "the sheet is open". A cold open
|
||||
* spends its first moment on the loading state, and stepping the count up
|
||||
* during it would land the whole first page in the one commit that follows —
|
||||
* the exact commit this exists to split.
|
||||
*/
|
||||
const LATEST_RELEASE_ONLY = 1;
|
||||
const FIRST_PAGE = 5;
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export interface RevealedReleases {
|
||||
count: number;
|
||||
showMore: () => void;
|
||||
}
|
||||
|
||||
export function useRevealedReleases(hasReleases: boolean): RevealedReleases {
|
||||
const [count, setCount] = useState(LATEST_RELEASE_ONLY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasReleases) {
|
||||
setCount(LATEST_RELEASE_ONLY);
|
||||
return;
|
||||
}
|
||||
startTransition(() => setCount(FIRST_PAGE));
|
||||
}, [hasReleases]);
|
||||
|
||||
const showMore = useCallback(() => {
|
||||
startTransition(() => setCount((current) => current + PAGE_SIZE));
|
||||
}, []);
|
||||
|
||||
return { count, showMore };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
|
||||
/** Opens the What's new sheet from anywhere, including outside React. */
|
||||
export function openChangelog(): void {
|
||||
useChangelogStore.getState().open();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceContentSearch } from "./workspace-content-search";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -10,7 +11,8 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -36,6 +38,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
@@ -90,7 +93,18 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const SCOPE_LABELS = {
|
||||
files: {
|
||||
chip: "shell.commandCenter.files",
|
||||
placeholder: "shell.commandCenter.filePlaceholder",
|
||||
},
|
||||
content: {
|
||||
chip: "shell.commandCenter.content",
|
||||
placeholder: "shell.commandCenter.contentPlaceholder",
|
||||
},
|
||||
} as const;
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
@@ -259,7 +273,7 @@ function useCommandCenterState(): CommandCenterState {
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
|
||||
query,
|
||||
});
|
||||
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
|
||||
@@ -584,6 +598,7 @@ export function CommandCenter() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const contentKeyHandler = useRef<((key: string) => boolean) | null>(null);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
@@ -700,18 +715,30 @@ export function CommandCenter() {
|
||||
onScroll: handleListScroll,
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
// Every scope wears the same header; only the chip and the placeholder name it.
|
||||
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
|
||||
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
|
||||
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
|
||||
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
|
||||
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
|
||||
// can never disagree about who is steering.
|
||||
const dispatchKey = useCallback(
|
||||
(key: string) =>
|
||||
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
|
||||
[dispatchKey],
|
||||
);
|
||||
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
if (!dispatchKey(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
[dispatchKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && state.open && !showBottomSheet,
|
||||
@@ -730,7 +757,9 @@ export function CommandCenter() {
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={COMMAND_CENTER_SNAP_POINTS}
|
||||
snapPoints={
|
||||
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
|
||||
}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
@@ -744,9 +773,7 @@ export function CommandCenter() {
|
||||
accessible={false}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
@@ -755,11 +782,7 @@ export function CommandCenter() {
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -770,49 +793,90 @@ export function CommandCenter() {
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
const desktopContent = (
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
<View style={[styles.overlay, isWeb && { zIndex: modalLayer }]}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
|
||||
>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{scopeChipLabel ? (
|
||||
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
return hostDesktopOverlay(desktopContent, state.close);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop web hosts the panel in the shared overlay root rather than React Native Web's `Modal`,
|
||||
* which paints in a plane of its own above it. Inside that plane an ordinary portal — the tooltip
|
||||
* on a result row — can never cover the panel however high its own z-index, so the row could not
|
||||
* show a reader its exact path. `adaptive-modal-sheet` already hosts desktop modals this way; see
|
||||
* docs/floating-panels.md. Native keeps the Modal.
|
||||
*/
|
||||
function hostDesktopOverlay(content: ReactElement, onRequestClose: () => void): ReactElement {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={onRequestClose}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
|
||||
@@ -841,7 +905,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
accessibilityLabel={label}
|
||||
onPress={onRemove}
|
||||
style={styles.scopeChip}
|
||||
testID="command-center-files-scope"
|
||||
testID="command-center-scope"
|
||||
>
|
||||
<ThemedFolder size={14} strokeWidth={2.2} />
|
||||
<Text style={styles.scopeChipLabel}>{label}</Text>
|
||||
@@ -852,12 +916,14 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { WorkspaceFileSource } from "@/file-pane/source";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { describeResultPath } from "./internal/result-path";
|
||||
import {
|
||||
WorkspaceContentSearchModel,
|
||||
type ContentSearchSnapshot,
|
||||
type ContentSearchTransport,
|
||||
} from "./internal/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
/** The Command Center owns the field; this body only reacts to what was typed in it. */
|
||||
query: string;
|
||||
compact: boolean;
|
||||
close(): void;
|
||||
clearScope(): void;
|
||||
keyHandler: React.RefObject<((key: string) => boolean) | null>;
|
||||
}
|
||||
|
||||
export function WorkspaceContentSearch(props: Props) {
|
||||
const { keyHandler, close, clearScope } = props;
|
||||
const { t } = useTranslation();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined));
|
||||
const unavailable =
|
||||
!session?.client ||
|
||||
!cwd ||
|
||||
!serverId ||
|
||||
!workspaceId ||
|
||||
!session.serverInfo?.features?.workspaceContentSearch;
|
||||
useEffect(() => {
|
||||
if (!unavailable) return;
|
||||
keyHandler.current = (key) => {
|
||||
if (key === "Escape") close();
|
||||
else if (key === "Backspace") clearScope();
|
||||
return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key);
|
||||
};
|
||||
return () => {
|
||||
keyHandler.current = null;
|
||||
};
|
||||
}, [unavailable, keyHandler, close, clearScope]);
|
||||
if (!session?.client || !cwd || !serverId || !workspaceId)
|
||||
return <Text style={styles.status}>{t("shell.commandCenter.contentUnavailable")}</Text>;
|
||||
// COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0.
|
||||
if (!session.serverInfo?.features?.workspaceContentSearch)
|
||||
return <Text style={styles.status}>{t("shell.commandCenter.contentUpdateHost")}</Text>;
|
||||
return (
|
||||
<ContentSearch
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
{...props}
|
||||
transport={session.client}
|
||||
cwd={cwd}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSearch({
|
||||
transport,
|
||||
cwd,
|
||||
serverId,
|
||||
workspaceId,
|
||||
query,
|
||||
compact,
|
||||
close,
|
||||
clearScope,
|
||||
keyHandler,
|
||||
}: Props & {
|
||||
transport: ContentSearchTransport;
|
||||
cwd: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const [previewPage, setPreviewPage] = useState(false);
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
const model = useMemo(
|
||||
() =>
|
||||
new WorkspaceContentSearchModel({
|
||||
cwd,
|
||||
transport,
|
||||
open(location) {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
const layout = useWorkspaceLayoutStore.getState();
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location,
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||
layout.openTab({ workspaceKey, target, intent: "reveal", placement }),
|
||||
focusWorkspaceTab: layout.focusTab,
|
||||
requestFileNavigation: layout.requestFileNavigation,
|
||||
});
|
||||
closeRef.current();
|
||||
},
|
||||
}),
|
||||
[cwd, transport, serverId, workspaceId],
|
||||
);
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
// The field lives in the Command Center header, so the typed value is a prop; this is the only
|
||||
// bridge from it into the model that owns the search.
|
||||
useEffect(() => {
|
||||
setPreviewPage(false);
|
||||
model.setQuery(query);
|
||||
}, [model, query]);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
useEffect(() => {
|
||||
if (state.matches.length && (!compact || !previewPage))
|
||||
listRef.current?.scrollToIndex({
|
||||
index: state.activeIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [compact, previewPage, state.activeIndex, state.matches]);
|
||||
const key = useCallback(
|
||||
(pressed: string) => {
|
||||
if (pressed === "Escape") {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !state.query) {
|
||||
clearScope();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "ArrowDown" || pressed === "ArrowUp") {
|
||||
model.move(pressed === "ArrowDown" ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
model.openSelected();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[close, clearScope, model, state.query],
|
||||
);
|
||||
useEffect(() => {
|
||||
keyHandler.current = key;
|
||||
return () => {
|
||||
if (keyHandler.current === key) keyHandler.current = null;
|
||||
};
|
||||
}, [keyHandler, key]);
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
model.select(index);
|
||||
if (compact) setPreviewPage(true);
|
||||
},
|
||||
[model, compact],
|
||||
);
|
||||
const back = useCallback(() => setPreviewPage(false), []);
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: WorkspaceContentMatch; index: number }) => (
|
||||
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
|
||||
),
|
||||
[select, state.activeIndex],
|
||||
);
|
||||
const emptyState = useMemo(
|
||||
() => <SearchState state={state} retry={model.retry} />,
|
||||
[model.retry, state],
|
||||
);
|
||||
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
{!compact || !previewPage ? (
|
||||
<View style={[styles.results, compact && styles.full]}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={state.matches}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyExtractor={resultKey}
|
||||
getItemLayout={resultLayout}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={emptyState}
|
||||
ListFooterComponent={limitNotice}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{!compact || previewPage ? (
|
||||
<ContentPreview state={state} model={model} compact={compact} back={back} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
item,
|
||||
index,
|
||||
selected,
|
||||
select,
|
||||
}: {
|
||||
item: WorkspaceContentMatch;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
select(index: number): void;
|
||||
}) {
|
||||
const onPress = useCallback(() => select(index), [index, select]);
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
|
||||
// keyboard cursor that drives the preview stays distinguishable from the pointer.
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.hovered,
|
||||
selected && styles.selected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
const line = useMemo(() => describeMatchLine(item), [item]);
|
||||
const file = useMemo(() => describeResultPath(item.path), [item.path]);
|
||||
const location = `${item.path}:${item.line}:${item.columnStart}`;
|
||||
return (
|
||||
// The resting row shows the file and its nearest parent, which two files can share
|
||||
// (packages/app/src/utils/index.ts and packages/server/src/utils/index.ts). The tooltip is
|
||||
// where the exact workspace-relative path lives; it is not bounded by the column's width.
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${location} ${item.snippet}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? selected : undefined}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
<Text style={styles.snippet} numberOfLines={1}>
|
||||
{line.before}
|
||||
<Text style={styles.match}>{line.match}</Text>
|
||||
{line.after}
|
||||
</Text>
|
||||
{/* The name and coordinates are pinned; only the parent context may shrink, so a deep
|
||||
path loses its middle rather than the identity the reader is scanning for. */}
|
||||
<View style={styles.location}>
|
||||
{file.directory ? (
|
||||
<Text style={styles.locationDirectory} numberOfLines={1}>
|
||||
{file.directory}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.locationFile} numberOfLines={1}>
|
||||
{file.name}:{item.line}:{item.columnStart}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" maxWidth={520} testID="content-search-row-path">
|
||||
<Text style={styles.tooltipPath}>{location}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
|
||||
function describeMatchLine(item: WorkspaceContentMatch) {
|
||||
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
|
||||
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
|
||||
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
|
||||
const before = item.snippet.slice(from, item.snippetMatchStart);
|
||||
return {
|
||||
before: cut ? `…${before}` : before.trimStart(),
|
||||
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
|
||||
after: item.snippet.slice(item.snippetMatchEnd),
|
||||
};
|
||||
}
|
||||
function resultKey(item: WorkspaceContentMatch) {
|
||||
return `${item.path}:${item.line}:${item.columnStart}`;
|
||||
}
|
||||
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
|
||||
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
|
||||
}
|
||||
|
||||
/** Idle stays blank: the header placeholder already says what the field does. */
|
||||
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "searching")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<ThemedSpinner size={14} />
|
||||
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "error")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "ready")
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
{/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling
|
||||
are never opened, so the empty state has to say so where the claim is made. */}
|
||||
{state.maxFileBytes ? (
|
||||
<Text style={styles.emptyDetail}>
|
||||
{t("shell.commandCenter.contentSkippedLargeFiles", {
|
||||
size: formatFileSize(state.maxFileBytes),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResultLimit({ limited }: { limited: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!limited) return null;
|
||||
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
|
||||
}
|
||||
|
||||
function ContentPreview({
|
||||
state,
|
||||
model,
|
||||
compact,
|
||||
back,
|
||||
}: {
|
||||
state: ContentSearchSnapshot;
|
||||
model: WorkspaceContentSearchModel;
|
||||
compact: boolean;
|
||||
back(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const match = state.matches[state.activeIndex];
|
||||
const preview = state.preview;
|
||||
const location = useMemo(
|
||||
() =>
|
||||
match
|
||||
? {
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
}
|
||||
: null,
|
||||
[match],
|
||||
);
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
{/* On desktop the selected row already names the file and Enter opens it, so the preview
|
||||
carries no header. The compact flow pages away from the list and still needs both. */}
|
||||
{compact && match ? (
|
||||
<View style={styles.previewHeader}>
|
||||
<Button variant="ghost" size="sm" onPress={back}>
|
||||
{t("shell.commandCenter.contentBack")}
|
||||
</Button>
|
||||
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
|
||||
{match.path}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.openSelected}>
|
||||
{t("shell.commandCenter.contentOpen")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "loading" ? (
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
|
||||
>
|
||||
<ThemedSpinner size={14} />
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "error" ? (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.retryPreview}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "ready" && location ? (
|
||||
<WorkspaceFileSource
|
||||
content={preview.content}
|
||||
filename={preview.path}
|
||||
size={preview.size}
|
||||
navigationRevision={state.activeIndex}
|
||||
location={location}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Denser than the Command Center's own two-line row: these results are scanned, not read, and a
|
||||
// search wants as many occurrences on screen as stay legible.
|
||||
const RESULT_ROW_HEIGHT = 44;
|
||||
// Characters of the matched line kept ahead of the match itself.
|
||||
const SNIPPET_LEAD = 24;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: { flex: 1, minHeight: 0, flexDirection: "row" },
|
||||
// A result row is two truncated single lines, so extra panel width buys the list nothing and
|
||||
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
|
||||
// desktop panel cannot starve the preview.
|
||||
results: {
|
||||
width: 360,
|
||||
maxWidth: "45%",
|
||||
minHeight: 0,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
},
|
||||
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
|
||||
row: {
|
||||
height: RESULT_ROW_HEIGHT,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
hovered: { backgroundColor: theme.colors.surface1 },
|
||||
selected: { backgroundColor: theme.colors.surface2 },
|
||||
// Three levels: the match is what the reader is looking for, the rest of the line is the
|
||||
// context it sits in, and the location is context for that.
|
||||
snippet: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 17,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
match: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: "600",
|
||||
backgroundColor: theme.colors.terminal.selectionBackground,
|
||||
},
|
||||
location: { flexDirection: "row", alignItems: "baseline" },
|
||||
locationFile: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 15,
|
||||
},
|
||||
locationDirectory: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 15,
|
||||
},
|
||||
preview: { flex: 1, minWidth: 0, minHeight: 0 },
|
||||
previewHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
status: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
empty: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
emptyDetail: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
limit: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,234 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function match(path: string, text = "NEEDLE"): WorkspaceContentMatch {
|
||||
return {
|
||||
path,
|
||||
line: 1,
|
||||
columnStart: 1,
|
||||
columnEnd: text.length + 1,
|
||||
text,
|
||||
snippet: text,
|
||||
snippetMatchStart: 0,
|
||||
snippetMatchEnd: text.length,
|
||||
};
|
||||
}
|
||||
function result(matches: WorkspaceContentMatch[]): WorkspaceContentSearchResult {
|
||||
return { status: "ok", matches, limited: false };
|
||||
}
|
||||
function file(path: string, text: string): FileReadResult {
|
||||
return {
|
||||
path,
|
||||
kind: "text",
|
||||
mime: "text/plain",
|
||||
bytes: new TextEncoder().encode(text),
|
||||
size: text.length,
|
||||
modifiedAt: "2026-09-09",
|
||||
};
|
||||
}
|
||||
|
||||
test("obsolete queries cannot restore results and opening carries the original case-insensitive match", async () => {
|
||||
const first = deferred<WorkspaceContentSearchResult>();
|
||||
const second = deferred<WorkspaceContentSearchResult>();
|
||||
const startedFirst = deferred<void>();
|
||||
const startedSecond = deferred<void>();
|
||||
const signals: AbortSignal[] = [];
|
||||
const opened: unknown[] = [];
|
||||
const transport: ContentSearchTransport = {
|
||||
searchWorkspaceContent: ({ query }, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (query === "old") {
|
||||
startedFirst.resolve();
|
||||
return first.promise;
|
||||
}
|
||||
startedSecond.resolve();
|
||||
return second.promise;
|
||||
},
|
||||
readFile: async (path) => file(path, "NEEDLE"),
|
||||
};
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
transport,
|
||||
open: (location) => opened.push(location),
|
||||
debounceMs: 0,
|
||||
});
|
||||
try {
|
||||
model.setQuery("old");
|
||||
await startedFirst.promise;
|
||||
model.setQuery("needle");
|
||||
await startedSecond.promise;
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
second.resolve(result([match("a.ts")]));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
first.resolve(result([match("obsolete.ts")]));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
query: "needle",
|
||||
status: "ready",
|
||||
matches: [match("a.ts")],
|
||||
preview: { status: "ready", content: "NEEDLE" },
|
||||
});
|
||||
model.openSelected();
|
||||
expect(opened).toEqual([
|
||||
{ path: "a.ts", lineStart: 1, columnStart: 1, columnEnd: 7, expectedText: "NEEDLE" },
|
||||
]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("selection bounds preview work to one read plus the latest target, and reads current saved content", async () => {
|
||||
const started = deferred<void>();
|
||||
const searched = deferred<WorkspaceContentSearchResult>();
|
||||
const a = deferred<FileReadResult>();
|
||||
const c = deferred<FileReadResult>();
|
||||
const paths: string[] = [];
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: () => {
|
||||
started.resolve();
|
||||
return searched.promise;
|
||||
},
|
||||
readFile: (_cwd, path) => {
|
||||
paths.push(path);
|
||||
return path === "a" ? a.promise : c.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await started.promise;
|
||||
searched.resolve(result([match("a"), match("b"), match("c")]));
|
||||
await Promise.resolve();
|
||||
model.select(1);
|
||||
model.select(2);
|
||||
expect(paths).toEqual(["a"]);
|
||||
a.resolve(file("a", "NEEDLE"));
|
||||
await Promise.resolve();
|
||||
expect(paths).toEqual(["a", "c"]);
|
||||
c.resolve(file("c", "changed"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().preview).toMatchObject({
|
||||
status: "ready",
|
||||
path: "c",
|
||||
content: "changed",
|
||||
});
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new query refreshes the saved preview even when its selected path is unchanged", async () => {
|
||||
const reads: Array<ReturnType<typeof deferred<FileReadResult>>> = [];
|
||||
const searches: Array<ReturnType<typeof deferred<WorkspaceContentSearchResult>>> = [];
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: () => {
|
||||
const pending = deferred<WorkspaceContentSearchResult>();
|
||||
searches.push(pending);
|
||||
return pending.promise;
|
||||
},
|
||||
readFile: () => {
|
||||
const pending = deferred<FileReadResult>();
|
||||
reads.push(pending);
|
||||
return pending.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("first");
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
searches[0].resolve(result([match("same.ts")]));
|
||||
await Promise.resolve();
|
||||
model.setQuery("second");
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
searches[1].resolve(result([match("same.ts")]));
|
||||
await Promise.resolve();
|
||||
reads[0].resolve(file("same.ts", "old saved text"));
|
||||
await Promise.resolve();
|
||||
expect(reads).toHaveLength(2);
|
||||
reads[1].resolve(file("same.ts", "NEEDLE current saved text"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().preview).toMatchObject({
|
||||
status: "ready",
|
||||
content: "NEEDLE current saved text",
|
||||
});
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("textual image occurrences expose saved source without changing the read kind", async () => {
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("icon.svg")]),
|
||||
readFile: async () => ({
|
||||
...file("icon.svg", "<svg>NEEDLE</svg>"),
|
||||
kind: "image",
|
||||
mime: "image/svg+xml",
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(model.getSnapshot().preview).toMatchObject({ content: "<svg>NEEDLE</svg>" });
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
|
||||
const reads: string[] = [];
|
||||
let deleted = false;
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
|
||||
readFile: async (_cwd, path) => {
|
||||
reads.push(path);
|
||||
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
|
||||
return file(path, `NEEDLE in ${path}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(reads).toEqual(["a.ts"]);
|
||||
model.move(1);
|
||||
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
|
||||
deleted = true;
|
||||
model.move(-1);
|
||||
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
|
||||
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
export interface ContentSearchTransport {
|
||||
searchWorkspaceContent(
|
||||
input: { cwd: string; query: string },
|
||||
options: { signal: AbortSignal },
|
||||
): Promise<WorkspaceContentSearchResult>;
|
||||
readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
): Promise<FileReadResult>;
|
||||
}
|
||||
type Preview =
|
||||
| { status: "empty" | "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; path: string; content: string; size: number };
|
||||
export interface ContentSearchSnapshot {
|
||||
query: string;
|
||||
status: "idle" | "searching" | "ready" | "error";
|
||||
message: string;
|
||||
matches: WorkspaceContentMatch[];
|
||||
limited: boolean;
|
||||
/** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */
|
||||
maxFileBytes: number | null;
|
||||
activeIndex: number;
|
||||
preview: Preview;
|
||||
}
|
||||
|
||||
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
|
||||
const MAX_PREVIEW_BYTES = 1_048_576;
|
||||
export class WorkspaceContentSearchModel {
|
||||
private snapshot: ContentSearchSnapshot = {
|
||||
query: "",
|
||||
status: "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: null,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private controller: AbortController | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private disposed = false;
|
||||
private reading = false;
|
||||
private file: { path: string; content: string; size: number } | null = null;
|
||||
constructor(
|
||||
private readonly options: {
|
||||
cwd: string;
|
||||
transport: ContentSearchTransport;
|
||||
open(location: WorkspaceFileLocation): void;
|
||||
debounceMs?: number;
|
||||
},
|
||||
) {}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getSnapshot = () => this.snapshot;
|
||||
private update(next: Partial<ContentSearchSnapshot>) {
|
||||
this.snapshot = { ...this.snapshot, ...next };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
setQuery = (query: string) => {
|
||||
this.controller?.abort();
|
||||
this.file = null;
|
||||
clearTimeout(this.timer);
|
||||
this.update({
|
||||
query,
|
||||
status: query ? "searching" : "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: null,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
});
|
||||
if (!query || this.disposed) return;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.timer = setTimeout(
|
||||
() => void this.search(query, controller),
|
||||
this.options.debounceMs ?? 100,
|
||||
);
|
||||
};
|
||||
private async search(query: string, controller: AbortController) {
|
||||
try {
|
||||
const result = await this.options.transport.searchWorkspaceContent(
|
||||
{ cwd: this.options.cwd, query },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (this.disposed || controller.signal.aborted) return;
|
||||
if (result.status === "error") {
|
||||
this.update({ status: "error", message: result.message });
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
status: "ready",
|
||||
matches: result.matches,
|
||||
limited: result.limited,
|
||||
maxFileBytes: result.maxFileBytes ?? null,
|
||||
});
|
||||
this.select(0);
|
||||
} catch (error) {
|
||||
if (!this.disposed && !controller.signal.aborted)
|
||||
this.update({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "File search failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
select = (index: number) => {
|
||||
const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1));
|
||||
this.update({ activeIndex });
|
||||
void this.preview();
|
||||
};
|
||||
move = (direction: number) =>
|
||||
this.select(
|
||||
(this.snapshot.activeIndex + direction + this.snapshot.matches.length) %
|
||||
(this.snapshot.matches.length || 1),
|
||||
);
|
||||
private async preview() {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match) return;
|
||||
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
|
||||
// tell the truth about a file that changed or disappeared since the search ran; see the
|
||||
// deleted-file recovery case in the browser spec before trading it for fewer reads.
|
||||
if (this.file?.path === match.path) {
|
||||
this.update({ preview: { status: "ready", ...this.file } });
|
||||
return;
|
||||
}
|
||||
this.update({ preview: { status: "loading" } });
|
||||
if (this.reading) return;
|
||||
this.reading = true;
|
||||
const path = match.path;
|
||||
const query = this.controller;
|
||||
try {
|
||||
const file = await this.options.transport.readFile(
|
||||
this.options.cwd,
|
||||
path,
|
||||
undefined,
|
||||
MAX_PREVIEW_BYTES,
|
||||
);
|
||||
if (this.disposed) return;
|
||||
if (query === this.controller) {
|
||||
const content = explorerFileFromReadResult(file).content;
|
||||
if (content === undefined) throw new Error("This file is no longer text");
|
||||
this.file = { path, content, size: file.size };
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.disposed &&
|
||||
query === this.controller &&
|
||||
this.snapshot.matches[this.snapshot.activeIndex]?.path === path
|
||||
) {
|
||||
this.update({
|
||||
preview: {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Preview unavailable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
this.reading = false;
|
||||
}
|
||||
if (!this.disposed) void this.preview();
|
||||
}
|
||||
retry = () => {
|
||||
this.file = null;
|
||||
this.setQuery(this.snapshot.query);
|
||||
};
|
||||
retryPreview = () => {
|
||||
this.file = null;
|
||||
void this.preview();
|
||||
};
|
||||
openSelected = () => {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match || this.snapshot.status !== "ready") return;
|
||||
this.options.open({
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
});
|
||||
};
|
||||
dispose = () => {
|
||||
this.disposed = true;
|
||||
this.controller?.abort();
|
||||
clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
this.file = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeResultPath } from "./result-path";
|
||||
|
||||
describe("describeResultPath", () => {
|
||||
it("keeps a root file whole", () => {
|
||||
expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" });
|
||||
});
|
||||
|
||||
it("keeps a shallow path whole", () => {
|
||||
expect(describeResultPath("docs/architecture.md")).toEqual({
|
||||
directory: "docs/",
|
||||
name: "architecture.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("elides the head of a deep path and keeps the distinguishing parent", () => {
|
||||
// These two rows are both "index.tsx"; the parents are the only thing telling them apart.
|
||||
expect(
|
||||
describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"),
|
||||
).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" });
|
||||
expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({
|
||||
directory: "…/source/",
|
||||
name: "index.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a literal backslash in a file name out of the directory", () => {
|
||||
expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* How a result row names its file.
|
||||
*
|
||||
* A flat list of occurrences is full of repeated base names — four `index.tsx` rows tell the
|
||||
* reader nothing. The nearest directories are what distinguish them, and they are also what an
|
||||
* end-truncated path throws away first. So the row keeps the file name and the closest parents,
|
||||
* and marks the elided head with a leading ellipsis.
|
||||
*/
|
||||
export interface ResultPathParts {
|
||||
/** Directory context immediately above the file, already elided at the head when deeper. */
|
||||
directory: string;
|
||||
/** File name, which is never truncated away. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const PARENTS_KEPT = 1;
|
||||
|
||||
export function describeResultPath(path: string): ResultPathParts {
|
||||
const segments = path.split("/");
|
||||
const name = segments.pop() ?? path;
|
||||
if (segments.length === 0) return { directory: "", name };
|
||||
const kept = segments.slice(-PARENTS_KEPT);
|
||||
const elided = segments.length > kept.length;
|
||||
return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name };
|
||||
}
|
||||
@@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
/** Splits a workspace-relative path the host produced, where "/" is the only separator. */
|
||||
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
const separator = path.lastIndexOf("/");
|
||||
return {
|
||||
path: normalized,
|
||||
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
|
||||
directory: separator >= 0 ? normalized.slice(0, separator) : "",
|
||||
path,
|
||||
name: separator >= 0 ? path.slice(separator + 1) : path,
|
||||
directory: separator >= 0 ? path.slice(0, separator) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Windows separators before opening or presenting a file", () => {
|
||||
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
|
||||
path: "src/components/message.tsx",
|
||||
name: "message.tsx",
|
||||
directory: "src/components",
|
||||
it("treats a backslash as part of the file name, because the host already sent separators as /", () => {
|
||||
expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({
|
||||
path: "a\\b.txt",
|
||||
name: "a\\b.txt",
|
||||
directory: "",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -128,8 +128,8 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
(path: string) => {
|
||||
if (!serverId || !workspaceId) return;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath: path,
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location: { path },
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
@@ -141,6 +141,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
placement,
|
||||
}),
|
||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
|
||||
});
|
||||
},
|
||||
[serverId, workspaceId],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -327,10 +328,30 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
],
|
||||
);
|
||||
|
||||
const allActions = useMemo(
|
||||
() => [
|
||||
...actions,
|
||||
{
|
||||
id: "workspace-content-search",
|
||||
group: "actions" as const,
|
||||
groupRank: 0,
|
||||
rank: 10,
|
||||
visibility: "query" as const,
|
||||
keywords: ["search", "find", "contents", "files", "text"],
|
||||
run: () => useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "content"),
|
||||
presentation: {
|
||||
kind: "action" as const,
|
||||
title: t("shell.commandCenter.contentTitle"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[actions, t],
|
||||
);
|
||||
useCommandCenterActions({
|
||||
sourceId: "workspace",
|
||||
enabled: Boolean(serverId && cwd),
|
||||
actions,
|
||||
actions: allActions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
desktopScrollContainer: {
|
||||
// Grows only when the card has an explicit `desktopHeight`; a content-sized
|
||||
// card has nothing to grow into. Without it a fixed-height card with short
|
||||
// content leaves the footer stranded in the middle.
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
|
||||
@@ -255,6 +255,7 @@ export function CombinedModelSelector({
|
||||
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
|
||||
<ModelProviderGlyph
|
||||
provider={selectedProvider}
|
||||
serverId={serverId}
|
||||
size={toolbar?.glyphSize ?? ICON_SIZE.md}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -14,7 +14,7 @@ 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";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import {
|
||||
@@ -119,6 +119,7 @@ export function CompactExplorerSidebarHost({
|
||||
[openTab],
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
@@ -136,16 +137,24 @@ export function CompactExplorerSidebarHost({
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location: { path: filePath },
|
||||
persistenceKey: model.persistenceKey,
|
||||
closeExplorerAfterOpen: presentation === "overlay",
|
||||
showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
focusWorkspaceTab,
|
||||
requestFileNavigation,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
|
||||
[
|
||||
focusWorkspaceTab,
|
||||
model,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
presentation,
|
||||
requestFileNavigation,
|
||||
showMobileAgent,
|
||||
],
|
||||
);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
|
||||
@@ -219,7 +219,7 @@ export function ModelProviderGlyph({
|
||||
tone = "muted",
|
||||
}: {
|
||||
provider: string;
|
||||
serverId?: string | null;
|
||||
serverId: string | null;
|
||||
size: number;
|
||||
tone?: ProviderGlyphTone;
|
||||
}) {
|
||||
|
||||
@@ -124,7 +124,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
@@ -284,6 +284,7 @@ interface WorkspaceRowInnerProps {
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
@@ -612,6 +613,7 @@ function WorkspaceRowRightGroup({
|
||||
archiveShortcutKeys,
|
||||
onArchive,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
@@ -631,6 +633,7 @@ function WorkspaceRowRightGroup({
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
onArchive?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -682,6 +685,7 @@ function WorkspaceRowRightGroup({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -1065,6 +1069,8 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
@@ -1135,6 +1141,8 @@ function WorkspaceRowInner({
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -1185,6 +1193,8 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
@@ -1286,15 +1296,21 @@ function WorkspaceRowWithMenu({
|
||||
const onTogglePin = canPin ? handleTogglePin : undefined;
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
@@ -1332,6 +1348,7 @@ function WorkspaceRowWithMenu({
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
|
||||
@@ -24,11 +24,11 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openChangelog } from "@/changelog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
const ThemedActivity = withUnistyles(Activity);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
@@ -97,10 +97,6 @@ export function SidebarHelpMenu() {
|
||||
void openExternalUrl(GITHUB_ISSUE_URL);
|
||||
}, []);
|
||||
|
||||
const openChangelog = useCallback(() => {
|
||||
void openExternalUrl(CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
|
||||
@@ -48,7 +48,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -641,15 +641,21 @@ function StatusWorkspaceRowWithMenu({
|
||||
const onTogglePin = canPin ? handleTogglePin : undefined;
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
@@ -682,6 +688,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
@@ -719,6 +726,7 @@ interface StatusWorkspaceRowInnerProps {
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
@@ -765,6 +773,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
@@ -860,6 +869,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -907,6 +917,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -937,6 +948,7 @@ function StatusWorkspaceActionSlot({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -956,6 +968,7 @@ function StatusWorkspaceActionSlot({
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -983,6 +996,7 @@ function StatusWorkspaceActionSlot({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
Copy,
|
||||
MoreVertical,
|
||||
@@ -52,6 +53,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
@@ -63,6 +65,7 @@ const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColor
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const markAsUnreadLeadingIcon = <ThemedCircle size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
@@ -85,6 +88,7 @@ export interface SidebarWorkspaceMenuProps {
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -132,6 +136,7 @@ function SidebarWorkspaceMenuItems({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -193,6 +198,16 @@ function SidebarWorkspaceMenuItems({
|
||||
Mark as read
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onMarkAsUnread ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-mark-as-unread-${workspaceKey}`}
|
||||
leading={markAsUnreadLeadingIcon}
|
||||
onSelect={onMarkAsUnread}
|
||||
>
|
||||
Mark as unread
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onTogglePin ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
@@ -243,6 +258,7 @@ export function SidebarWorkspaceMenu({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -288,6 +304,7 @@ export function SidebarWorkspaceMenu({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -320,6 +337,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -399,6 +417,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -123,15 +123,21 @@ export function SidebarWorkspaceRow({
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
@@ -166,6 +172,7 @@ export function SidebarWorkspaceRow({
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
@@ -198,6 +205,7 @@ interface WorkspaceRowBodyProps {
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
@@ -221,6 +229,7 @@ function WorkspaceRowBody({
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -293,6 +302,7 @@ function WorkspaceRowBody({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -340,6 +350,7 @@ function WorkspaceRowBody({
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
@@ -365,6 +376,7 @@ function WorkspaceRowTrailingActions({
|
||||
archiveShortcutKeys,
|
||||
onArchive,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
@@ -383,6 +395,7 @@ function WorkspaceRowTrailingActions({
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
onArchive?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -430,6 +443,7 @@ function WorkspaceRowTrailingActions({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, CancelledError } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages";
|
||||
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import { applyProvidersSnapshotUpdate } from "./push-router";
|
||||
import { createProviderSnapshotCache } from "./provider-snapshot-cache";
|
||||
import {
|
||||
resolveProviderIconName,
|
||||
@@ -296,3 +297,41 @@ it("keeps a replacement query when the cancelled native fetch finishes late", as
|
||||
replaceProviderSnapshotIcons(serverId, []);
|
||||
}
|
||||
});
|
||||
|
||||
describe("provider snapshot publication identity", () => {
|
||||
it("keeps the hash cache's canonical objects across a catalog change and later announcements", async () => {
|
||||
const cache = createProviderSnapshotCache(createStorage());
|
||||
const queryClient = new QueryClient();
|
||||
const serverId = "canonical-provider-snapshot";
|
||||
const client = {
|
||||
async getProvidersSnapshot() {
|
||||
throw new Error("Unexpected content fetch");
|
||||
},
|
||||
};
|
||||
const changed = {
|
||||
...snapshot,
|
||||
snapshotHash: "next-hash",
|
||||
compactSnapshot: compactProviderSnapshot([{ ...entries[0]!, label: "Updated provider" }]),
|
||||
};
|
||||
try {
|
||||
for (const body of [snapshot, changed, changed]) {
|
||||
await applyProvidersSnapshotUpdate({
|
||||
serverId,
|
||||
queryClient,
|
||||
cache,
|
||||
client,
|
||||
message: { type: "providers_snapshot_update", payload: body },
|
||||
});
|
||||
const canonical = await cache.materialize(serverId, body);
|
||||
const published = queryClient.getQueryData<GetProvidersSnapshotResponseMessage["payload"]>(
|
||||
providersSnapshotQueryKey(serverId, body.cwd),
|
||||
)!;
|
||||
expect(published.entries).toBe(canonical.entries);
|
||||
expect(published.compactSnapshot).toBe(canonical.compactSnapshot);
|
||||
expect(published.entries[0]!.models).toBe(canonical.entries[0]!.models);
|
||||
}
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function fetchProvidersSnapshot(input: {
|
||||
const body = await queryClient.fetchQuery({
|
||||
queryKey: ["providerSnapshotContent", input.serverId, hash],
|
||||
gcTime: 0,
|
||||
structuralSharing: false,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -134,6 +135,7 @@ export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
queryFn: ({ signal }) => fetchProvidersSnapshot({ ...input, signal }),
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
|
||||
@@ -146,6 +146,77 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||
}
|
||||
|
||||
describe("server data push router", () => {
|
||||
it("retains equal diff files without reconstructing changed file bodies", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "diff-sharing";
|
||||
const cwd = "/repo";
|
||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false);
|
||||
const subscriptionId = "diff-sharing";
|
||||
queryClient.getQueryCache().build(queryClient, {
|
||||
queryKey,
|
||||
queryFn: skipToken,
|
||||
meta: checkoutDiffPushRoute({
|
||||
enabled: false,
|
||||
serverId,
|
||||
cwd,
|
||||
subscriptionId,
|
||||
compare: { mode: "uncommitted" },
|
||||
}),
|
||||
});
|
||||
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
|
||||
type Payload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
const files: Payload["files"] = ["a.ts", "b.ts"].map((path) => ({
|
||||
path,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 2,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{
|
||||
type: "add",
|
||||
content: "const answer = 42",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " answer = 42", style: null },
|
||||
],
|
||||
},
|
||||
{ type: "add", content: "answer", tokens: [{ text: "answer", style: null }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
const publish = (incomingFiles: Payload["files"], requestId: string) => {
|
||||
fake.emit({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: { subscriptionId, cwd, files: incomingFiles, requestId, error: null },
|
||||
});
|
||||
return queryClient.getQueryData<Payload>(queryKey)!;
|
||||
};
|
||||
try {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("reopen");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
expect(next.files[1]).toBe(first.files[1]);
|
||||
expect(next.files[0]).toBe(changed[0]);
|
||||
expect(next.files[0]!.hunks[0]!.lines[0]!.tokens![0]!.style).toBe("variable");
|
||||
expect(publish([], "deleted").files).toEqual([]);
|
||||
} finally {
|
||||
unmount();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes provider snapshot and daemon config payloads until detached", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
@@ -203,6 +204,7 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
retry: false,
|
||||
queryFn: ({ signal }) => {
|
||||
const incoming = announcement;
|
||||
@@ -510,6 +512,7 @@ function setCheckoutDiffPayload(input: {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
query.setOptions({ ...query.options, structuralSharing: shareCheckoutDiff });
|
||||
input.queryClient.setQueryData<CheckoutDiffCachePayload>(query.queryKey, input.payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> =
|
||||
> & {
|
||||
dataShape: "list" | "value";
|
||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||
staleTimeMs: number;
|
||||
};
|
||||
} & (
|
||||
| { staleTimeMs: number; immutableWhen?: never }
|
||||
| { staleTimeMs?: never; immutableWhen: (data: TQueryFnData) => boolean }
|
||||
);
|
||||
|
||||
export function useReplicaQuery<
|
||||
TQueryFnData,
|
||||
@@ -88,7 +90,7 @@ function replicaQueryOptions<
|
||||
};
|
||||
}
|
||||
|
||||
function fetchQueryOptions<
|
||||
export function fetchQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
@@ -96,11 +98,11 @@ function fetchQueryOptions<
|
||||
>(
|
||||
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
||||
if (!Number.isFinite(input.staleTimeMs)) {
|
||||
if (!input.immutableWhen && !Number.isFinite(input.staleTimeMs)) {
|
||||
throw new Error("Fetch queries must declare a finite staleTimeMs.");
|
||||
}
|
||||
|
||||
const { dataShape, meta, staleTimeMs, ...options } = input;
|
||||
const { dataShape, meta, staleTimeMs, immutableWhen, ...options } = input;
|
||||
return {
|
||||
...options,
|
||||
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
|
||||
@@ -111,7 +113,10 @@ function fetchQueryOptions<
|
||||
dataShape,
|
||||
},
|
||||
},
|
||||
refetchOnMount: "always",
|
||||
staleTime: staleTimeMs,
|
||||
refetchOnMount: immutableWhen ? true : "always",
|
||||
staleTime: immutableWhen
|
||||
? (query) =>
|
||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||
: staleTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} from "@/desktop/updates/resolve-update-callout";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { openChangelog } from "@/changelog";
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function renderBody(body: UpdateCalloutBody, t: ReturnType<typeof useTranslation>["t"]): ReactNode {
|
||||
if (body.kind === "installing") return t("desktop.updates.callout.installingDescription");
|
||||
@@ -52,9 +51,6 @@ export function UpdateCalloutSource() {
|
||||
} = useDesktopAppUpdater();
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const openChangelog = useStableEvent(() => {
|
||||
void openExternalUrl(CHANGELOG_URL);
|
||||
});
|
||||
const install = useStableEvent(() => {
|
||||
void installUpdate();
|
||||
});
|
||||
@@ -111,7 +107,6 @@ export function UpdateCalloutSource() {
|
||||
install,
|
||||
isDesktopApp,
|
||||
isInstalling,
|
||||
openChangelog,
|
||||
retry,
|
||||
status,
|
||||
theme.colors.foregroundMuted,
|
||||
|
||||
@@ -72,3 +72,9 @@ describe("resolveFilePreviewReadTarget", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal whitespace for a source read target", () => {
|
||||
expect(
|
||||
resolveFilePreviewReadTarget({ path: " leading.txt ", workspaceRoot: "/workspace " }),
|
||||
).toEqual({ cwd: "/workspace ", path: " leading.txt " });
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export function resolveFilePreviewReadTarget(input: {
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): FilePreviewReadTarget | null {
|
||||
const previewPath = input.path.trim();
|
||||
const previewPath = input.path;
|
||||
if (!previewPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
const workspaceRoot = input.workspaceRoot;
|
||||
if (!isAbsolutePath(previewPath)) {
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
|
||||
@@ -32,3 +32,20 @@ describe("explorerFileFromReadResult", () => {
|
||||
expect(embedded.hasBom).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps SVG image identity and exposes valid UTF-8 source for occurrence navigation", () => {
|
||||
const file = {
|
||||
...textRead(new TextEncoder().encode("<svg>needle</svg>")),
|
||||
kind: "image" as const,
|
||||
mime: "image/svg+xml",
|
||||
path: "icon.svg",
|
||||
};
|
||||
expect(explorerFileFromReadResult(file)).toMatchObject({
|
||||
kind: "image",
|
||||
content: "<svg>needle</svg>",
|
||||
});
|
||||
expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({
|
||||
kind: "image",
|
||||
content: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,21 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
|
||||
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
||||
const isText = file.kind === "text";
|
||||
// SVG keeps its normal image presentation while exposing its UTF-8 source to line targets.
|
||||
const isText = file.kind === "text" || file.mime === "image/svg+xml";
|
||||
let content: string | undefined;
|
||||
if (isText) {
|
||||
try {
|
||||
content = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
|
||||
} catch (error) {
|
||||
if (file.kind === "text") throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
kind: file.kind,
|
||||
encoding: isText ? "utf-8" : "none",
|
||||
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
|
||||
content,
|
||||
hasBom: isText && hasUtf8Bom(file.bytes),
|
||||
mimeType: file.mime,
|
||||
size: file.size,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -115,12 +116,6 @@ export function FilePanelBar({
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
@@ -42,7 +41,6 @@ export function editorBaseExtensions(onSave: () => void) {
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -63,6 +61,13 @@ export function editorTheme(theme: EditorVisualTheme) {
|
||||
fontFamily: theme.monoFont,
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-panels": { backgroundColor: theme.background, color: theme.foreground },
|
||||
".cm-panels-top": { borderBottom: "none" },
|
||||
".cm-searchMatch": { backgroundColor: theme.selection },
|
||||
".cm-searchMatch-selected": {
|
||||
backgroundColor: theme.selection,
|
||||
outline: `1px solid ${theme.cursor}`,
|
||||
},
|
||||
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||
".cm-gutters": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
@@ -38,6 +40,8 @@ export function FileEditorView({
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
@@ -54,6 +58,7 @@ export function FileEditorView({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||
find.extension,
|
||||
...editorBaseExtensions(() => void values.model.save()),
|
||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||
wrappingCompartment.of(wrappingForFile(values.filename)),
|
||||
@@ -81,7 +86,7 @@ export function FileEditorView({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [find]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -99,15 +104,12 @@ export function FileEditorView({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
selection: { anchor: from, head: to },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
}, [location, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
@@ -140,16 +142,34 @@ export function FileEditorView({
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
const changed = resolveWorkspaceFileSelection(
|
||||
snapshot.content.replace(/\r\n?/g, "\n"),
|
||||
location,
|
||||
).changed;
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocusIfAny } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { FileFindModel } from "./model.web";
|
||||
|
||||
export { FileFindModel } from "./model.web";
|
||||
|
||||
export function FileFind({
|
||||
model,
|
||||
editor,
|
||||
}: {
|
||||
model: FileFindModel;
|
||||
editor: RefObject<EditorView | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
// A source view can also render outside any pane, such as the Command Center content
|
||||
// preview. Pane Find belongs to the pane, so there is nothing to open there.
|
||||
const pane = usePaneFocusIfAny();
|
||||
const active = useRetainedPanelActive();
|
||||
// RN Web hands the underlying DOM element to a View ref; the model measures it.
|
||||
const setWidgetNode = useCallback(
|
||||
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
|
||||
[model],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pane?.isInteractive || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
event.preventDefault();
|
||||
model.open(editor.current);
|
||||
widget.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [active, editor, model, pane?.isInteractive]);
|
||||
|
||||
const replace = useMemo(
|
||||
() =>
|
||||
state.readOnly
|
||||
? undefined
|
||||
: {
|
||||
value: state.replacement,
|
||||
onChange: model.setReplacement,
|
||||
onReplace: model.replace,
|
||||
onReplaceAll: model.replaceAll,
|
||||
},
|
||||
[model, state.readOnly, state.replacement],
|
||||
);
|
||||
if (!pane || !state.open) return null;
|
||||
const total = `${state.total}${state.limited ? "+" : ""}`;
|
||||
let status = "";
|
||||
if (state.query) {
|
||||
if (state.total === 0) status = t("paneFind.noMatches");
|
||||
else if (state.current) status = t("paneFind.position", { current: state.current, total });
|
||||
else status = t("paneFind.total", { total });
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View ref={setWidgetNode} style={styles.widget}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The widget floats over the content; the model flips the corner when it would
|
||||
// otherwise sit on top of the active match.
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
alignItems: "flex-end",
|
||||
zIndex: 1,
|
||||
},
|
||||
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
|
||||
widget: { maxWidth: "100%" },
|
||||
overlayTop: { top: theme.spacing[2] },
|
||||
overlayBottom: { bottom: theme.spacing[2] },
|
||||
}));
|
||||
@@ -0,0 +1,255 @@
|
||||
import { EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view";
|
||||
import {
|
||||
closeSearchPanel,
|
||||
findNext,
|
||||
findPrevious,
|
||||
getSearchQuery,
|
||||
openSearchPanel,
|
||||
replaceAll,
|
||||
replaceNext,
|
||||
search,
|
||||
searchKeymap,
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
} from "@codemirror/search";
|
||||
|
||||
const MATCH_STATUS_LIMIT = 10_000;
|
||||
|
||||
/** Which corner of the editor the floating Find widget occupies. */
|
||||
export type FindPlacement = "top" | "bottom";
|
||||
|
||||
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
|
||||
export class FileFindModel {
|
||||
private view: EditorView | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private matches: Array<{ from: number; to: number }> = [];
|
||||
private widget: HTMLElement | null = null;
|
||||
private widgetResize: ResizeObserver | null = null;
|
||||
private snapshot = {
|
||||
open: false,
|
||||
placement: "top" as FindPlacement,
|
||||
query: "",
|
||||
replacement: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
limited: false,
|
||||
readOnly: true,
|
||||
};
|
||||
readonly subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
readonly getSnapshot = () => this.snapshot;
|
||||
|
||||
readonly extension: Extension = [
|
||||
// The widget floats over the editor as a sibling overlay, so this panel is an
|
||||
// inert placeholder that only reports whether Find is open. Hide the placeholder
|
||||
// itself, never the shared panel container — Go to line's dialog lives there too.
|
||||
EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }),
|
||||
// Reveal matches clear of the widget instead of underneath it.
|
||||
EditorView.scrollMargins.of((view) => {
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return null;
|
||||
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
|
||||
}),
|
||||
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
|
||||
EditorState.transactionExtender.of((transaction) => {
|
||||
// CodeMirror seeds every search-opening command from the selection. A
|
||||
// single-line field cannot represent CR/LF, so keep the previous query.
|
||||
const multilineSeed = transaction.effects.some(
|
||||
(effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search),
|
||||
);
|
||||
if (!multilineSeed) return null;
|
||||
return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) };
|
||||
}),
|
||||
keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")),
|
||||
];
|
||||
|
||||
readonly open = (view = this.view) => {
|
||||
if (view) openSearchPanel(view);
|
||||
};
|
||||
readonly close = () => {
|
||||
if (this.view) {
|
||||
closeSearchPanel(this.view);
|
||||
this.view.focus();
|
||||
}
|
||||
};
|
||||
readonly next = () => {
|
||||
if (this.view) findNext(this.view);
|
||||
};
|
||||
readonly previous = () => {
|
||||
if (this.view) findPrevious(this.view);
|
||||
};
|
||||
readonly replace = () => {
|
||||
if (this.view) replaceNext(this.view);
|
||||
};
|
||||
readonly replaceAll = () => {
|
||||
if (this.view) replaceAll(this.view);
|
||||
};
|
||||
readonly setReplacement = (replacement: string) =>
|
||||
this.setQuery(this.snapshot.query, replacement);
|
||||
readonly setSearch = (query: string) => {
|
||||
this.setQuery(query, this.snapshot.replacement);
|
||||
if (this.view && this.matches.length > 0) {
|
||||
this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } });
|
||||
findNext(this.view);
|
||||
}
|
||||
};
|
||||
|
||||
private setQuery(query: string, replacement: string) {
|
||||
this.view?.dispatch({
|
||||
effects: setSearchQuery.of(
|
||||
new SearchQuery({ search: query, replace: replacement, literal: true }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
|
||||
* node here lets this model — which already owns reveal — measure the obstruction.
|
||||
*/
|
||||
readonly setWidgetNode = (node: HTMLElement | null) => {
|
||||
if (this.widget === node) return;
|
||||
this.widgetResize?.disconnect();
|
||||
this.widgetResize = null;
|
||||
this.widget = node;
|
||||
if (!node) return;
|
||||
this.widgetResize = new ResizeObserver(() => this.reposition());
|
||||
this.widgetResize.observe(node);
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
private createPanel(view: EditorView): Panel {
|
||||
this.view = view;
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "paseo-file-find";
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
mount: () => {
|
||||
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
|
||||
this.update(view, true);
|
||||
},
|
||||
update: (update: ViewUpdate) => {
|
||||
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
queryChanged ||
|
||||
update.geometryChanged ||
|
||||
update.viewportChanged
|
||||
)
|
||||
this.update(view, update.docChanged || queryChanged);
|
||||
},
|
||||
destroy: () => {
|
||||
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
|
||||
this.publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical space the widget takes out of the editor, including its inset. */
|
||||
private clearance(view: EditorView): number {
|
||||
const widget = this.widget;
|
||||
if (!widget || !this.snapshot.open) return 0;
|
||||
const box = widget.getBoundingClientRect();
|
||||
if (!box.height) return 0;
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const clearance =
|
||||
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
|
||||
return Math.max(0, clearance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the widget to the opposite corner when it sits on top of the active match
|
||||
* and the editor has no scroll range left to reveal it. Placement only changes
|
||||
* when the current corner collides, so navigating matches never makes it bounce.
|
||||
*
|
||||
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
|
||||
* reads while an update is in progress, and every caller here sits inside one.
|
||||
*/
|
||||
private reposition() {
|
||||
const view = this.view;
|
||||
if (!view || !this.widget || !this.snapshot.open) return;
|
||||
view.requestMeasure<FindPlacement>({
|
||||
key: this,
|
||||
read: () => this.measurePlacement(view),
|
||||
write: (placement) => {
|
||||
if (placement === this.snapshot.placement) return;
|
||||
this.snapshot = { ...this.snapshot, placement };
|
||||
this.publish();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private measurePlacement(view: EditorView): FindPlacement {
|
||||
const current = this.snapshot.placement;
|
||||
const widget = this.widget;
|
||||
if (!widget) return current;
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) return current;
|
||||
const from = view.coordsAtPos(selection.from);
|
||||
const to = view.coordsAtPos(selection.to);
|
||||
if (!from || !to) return current;
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return current;
|
||||
const box = widget.getBoundingClientRect();
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const match = {
|
||||
top: Math.min(from.top, to.top),
|
||||
bottom: Math.max(from.bottom, to.bottom),
|
||||
right: Math.max(from.right, to.right),
|
||||
};
|
||||
const hits = (placement: FindPlacement) => {
|
||||
if (match.right <= box.left) return false;
|
||||
return placement === "top"
|
||||
? match.top < editor.top + clearance && match.bottom > editor.top
|
||||
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
|
||||
};
|
||||
if (!hits(current)) return current;
|
||||
const other: FindPlacement = current === "top" ? "bottom" : "top";
|
||||
return hits(other) ? current : other;
|
||||
}
|
||||
|
||||
private update(view: EditorView, recount: boolean) {
|
||||
const query = getSearchQuery(view.state);
|
||||
let limited = this.snapshot.limited;
|
||||
if (recount) {
|
||||
this.matches = [];
|
||||
limited = false;
|
||||
if (query.valid) {
|
||||
const cursor = query.getCursor(view.state);
|
||||
for (let match = cursor.next(); !match.done; match = cursor.next()) {
|
||||
if (this.matches.length === MATCH_STATUS_LIMIT) {
|
||||
limited = true;
|
||||
break;
|
||||
}
|
||||
this.matches.push(match.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const selection = view.state.selection.main;
|
||||
const current =
|
||||
this.matches.findIndex(
|
||||
(match) => match.from === selection.from && match.to === selection.to,
|
||||
) + 1;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
query: query.search,
|
||||
replacement: query.replace,
|
||||
current,
|
||||
total: this.matches.length,
|
||||
limited,
|
||||
readOnly: view.state.readOnly,
|
||||
};
|
||||
this.publish();
|
||||
this.reposition();
|
||||
}
|
||||
|
||||
private publish() {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,14 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { FileSourceView } from "./source/view";
|
||||
import { WorkspaceFileSource } from "./source";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -58,24 +59,6 @@ interface FilePreviewBodyProps {
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ReadonlySource({
|
||||
preview,
|
||||
filename,
|
||||
@@ -87,32 +70,13 @@ function ReadonlySource({
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
<WorkspaceFileSource
|
||||
content={preview.content ?? ""}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
size={preview.size}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +86,7 @@ function TooLargeSource({ size }: { size?: number }) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize(size)}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +128,7 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
|
||||
if (renderKind === "html") {
|
||||
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
|
||||
return (
|
||||
@@ -214,7 +178,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -239,8 +203,8 @@ export function FilePane({
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const normalizedWorkspaceRoot = workspaceRoot;
|
||||
const normalizedFilePath = location.path || null;
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { FileSourceView } from "./view";
|
||||
|
||||
/** Read-only source preview. The source module owns platform rendering and its theme. */
|
||||
export function WorkspaceFileSource(props: {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
{...props}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
@@ -58,14 +59,16 @@ function VirtualizedSource({
|
||||
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||
presentation: "highlighted" | "plain";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||
const lines = useMemo(() => {
|
||||
const source = content.replace(/\r\n?/g, "\n");
|
||||
if (presentation === "highlighted")
|
||||
return highlightCode(content, filename).map((tokens, index) => ({
|
||||
return highlightCode(source, filename).map((tokens, index) => ({
|
||||
number: index + 1,
|
||||
tokens,
|
||||
}));
|
||||
return content
|
||||
return source
|
||||
.split("\n")
|
||||
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||
}, [content, filename, presentation]);
|
||||
@@ -77,29 +80,63 @@ function VirtualizedSource({
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [lines.length, location.lineStart, navigationRevision]);
|
||||
const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
const changed =
|
||||
location.expectedText !== undefined &&
|
||||
selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !==
|
||||
location.expectedText;
|
||||
const renderLine = useCallback(
|
||||
({ item }: { item: SourceLine }) => (
|
||||
<SourceLineView
|
||||
line={item}
|
||||
start={!changed && item.number === location.lineStart ? location.columnStart : undefined}
|
||||
end={!changed && item.number === location.lineStart ? location.columnEnd : undefined}
|
||||
/>
|
||||
),
|
||||
[changed, location],
|
||||
);
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
renderItem={renderSourceLine}
|
||||
/>
|
||||
<View style={styles.root}>
|
||||
{changed ? (
|
||||
<Text accessibilityRole="alert" style={styles.notice}>
|
||||
{t("shell.commandCenter.contentChanged")}
|
||||
</Text>
|
||||
) : null}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
extraData={location}
|
||||
renderItem={renderLine}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLineView({ line }: { line: SourceLine }) {
|
||||
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
|
||||
let offset = 0;
|
||||
return (
|
||||
<View style={styles.line}>
|
||||
<Text style={styles.gutter}>{line.number}</Text>
|
||||
<Text selectable style={styles.text}>
|
||||
{line.tokens.map((token) => (
|
||||
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
{line.tokens.map((token) => {
|
||||
const tokenStart = offset;
|
||||
offset += token.text.length;
|
||||
const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart));
|
||||
const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart));
|
||||
return (
|
||||
<Text key={`${tokenStart}:${token.style}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text.slice(0, from)}
|
||||
<Text style={styles.match}>{token.text.slice(from, to)}</Text>
|
||||
{token.text.slice(to)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -113,9 +150,14 @@ function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index
|
||||
return { length: 20, offset: index * 20, index };
|
||||
}
|
||||
|
||||
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
notice: {
|
||||
padding: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
match: { backgroundColor: theme.colors.terminal.selectionBackground },
|
||||
unsupported: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorView, drawSelection } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
@@ -59,6 +61,8 @@ function ReadonlyCodeMirror({
|
||||
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const initial = useRef({ content, filename, presentation, theme });
|
||||
@@ -71,7 +75,15 @@ function ReadonlyCodeMirror({
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
find.extension,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({
|
||||
tabindex: "0",
|
||||
"aria-label": `Source for ${values.filename}`,
|
||||
}),
|
||||
// The preview is never focused, so the browser draws no selection of its own; the
|
||||
// exact occurrence has to be painted by CodeMirror.
|
||||
drawSelection(),
|
||||
EditorView.editable.of(false),
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
@@ -85,7 +97,7 @@ function ReadonlyCodeMirror({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [find]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -105,12 +117,22 @@ function ReadonlyCodeMirror({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(line).from;
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||
}, [location.lineStart, navigationRevision]);
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: to },
|
||||
// A match far into a long line lands at the scroller edge without the horizontal centre.
|
||||
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
|
||||
});
|
||||
}, [content, location, navigationRevision]);
|
||||
|
||||
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
|
||||
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
|
||||
return (
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function languageFor(input: {
|
||||
@@ -122,6 +144,16 @@ function languageFor(input: {
|
||||
: [];
|
||||
}
|
||||
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
const UNSUPPORTED_STYLE = {
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import { commitFileDiffQueryOptions } from "./commit-file-diff-query";
|
||||
|
||||
const file = { path: "a.ts", isNew: true, isDeleted: false, additions: 0, deletions: 0, hunks: [] };
|
||||
|
||||
describe("commit file diff fetching", () => {
|
||||
it("reuses a cached commit body on remount and fetches on explicit refresh", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
let calls = 0;
|
||||
const options = fetchQueryOptions(
|
||||
commitFileDiffQueryOptions({
|
||||
serverId: "host",
|
||||
cwd: "/repo",
|
||||
sha: "abc",
|
||||
path: "a.ts",
|
||||
enabled: true,
|
||||
client: {
|
||||
async getCommitFileDiff() {
|
||||
calls++;
|
||||
return { file };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
options.queryKey,
|
||||
{ file },
|
||||
{ updatedAt: Date.now() - 24 * 60 * 60 * 1000 },
|
||||
);
|
||||
const observer = new QueryObserver(queryClient, options);
|
||||
const detach = observer.subscribe(() => {});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(calls).toBe(0);
|
||||
expect(observer.getCurrentResult().data?.file).toBe(file);
|
||||
await observer.refetch();
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
detach();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a cached null result when reopened", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const options = fetchQueryOptions(
|
||||
commitFileDiffQueryOptions({
|
||||
serverId: "host",
|
||||
cwd: "/repo",
|
||||
sha: "abc",
|
||||
path: "a.ts",
|
||||
enabled: true,
|
||||
client: {
|
||||
async getCommitFileDiff() {
|
||||
return { file };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.setQueryData(options.queryKey, { file: null });
|
||||
const observer = new QueryObserver(queryClient, options);
|
||||
const settled = Promise.withResolvers<void>();
|
||||
const detach = observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle") settled.resolve();
|
||||
});
|
||||
try {
|
||||
await settled.promise;
|
||||
expect(observer.getCurrentResult().data?.file).toEqual(file);
|
||||
} finally {
|
||||
detach();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "./query-keys";
|
||||
import { shareCommitFileDiff } from "./diff-sharing";
|
||||
|
||||
export function commitFileDiffQueryOptions(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
sha: string;
|
||||
path: string;
|
||||
client: Pick<DaemonClient, "getCommitFileDiff"> | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
return {
|
||||
queryKey: checkoutCommitFileDiffQueryKey(input.serverId, input.cwd, input.sha, input.path),
|
||||
queryFn: () => {
|
||||
if (!input.client) throw new Error("Host disconnected");
|
||||
return input.client.getCommitFileDiff(input.cwd, input.sha, input.path);
|
||||
},
|
||||
enabled: input.enabled,
|
||||
gcTime: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
// Only successful file bodies are immutable. Null/error results can recover.
|
||||
immutableWhen: (data: Awaited<ReturnType<DaemonClient["getCommitFileDiff"]>>) =>
|
||||
data.file !== null,
|
||||
dataShape: "value" as const,
|
||||
refetchOnWindowFocus: false,
|
||||
structuralSharing: shareCommitFileDiff,
|
||||
};
|
||||
}
|
||||
@@ -26,9 +26,10 @@ export function HorizontalScroll({
|
||||
const scrollId = useId();
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const edgeRef = useRef(true);
|
||||
const path = file.path;
|
||||
const handler = useAnimatedScrollHandler({
|
||||
onScroll(event) {
|
||||
offsets.value = { ...offsets.value, [file.path]: event.contentOffset.x };
|
||||
offsets.value = { ...offsets.value, [path]: event.contentOffset.x };
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -71,6 +71,55 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
}
|
||||
|
||||
describe("diff document model", () => {
|
||||
it("materializes only visible rows of a large file while preserving full horizontal extent", () => {
|
||||
const source = file("large.ts");
|
||||
source.hunks[0]!.lines = Array.from({ length: 1000 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: index === 900 ? "a very wide offscreen line ".repeat(20) : `line ${index}`,
|
||||
tokens: [{ text: `line ${index}`, style: null }],
|
||||
}));
|
||||
let advancedCharacters = 0;
|
||||
const measureText: TextMeasurer = {
|
||||
measure: (text) => text.length * 10,
|
||||
measureAdvances(graphemes) {
|
||||
advancedCharacters += graphemes.join("").length;
|
||||
return graphemes.map((_, index) => (index + 1) * 10);
|
||||
},
|
||||
};
|
||||
const base = input({ files: [source], wrapLines: false, viewportWidth: 300, measureText });
|
||||
const eager = buildDiffDocumentModel(base);
|
||||
advancedCharacters = 0;
|
||||
const first = buildDiffDocumentModel({
|
||||
...base,
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
|
||||
});
|
||||
expect(measuredFragments(first, source.path)).toBe(3);
|
||||
expect(advancedCharacters).toBeLessThan(100);
|
||||
expect(first.height).toBe(eager.height);
|
||||
expect(first.files[0]!.contentWidth).toBe(eager.files[0]!.contentWidth);
|
||||
const priorCells = firstLineCells(first, source.path);
|
||||
advancedCharacters = 0;
|
||||
const next = buildDiffDocumentModel({
|
||||
...base,
|
||||
reuseFrom: [first],
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT + 900 * 18, height: 54 },
|
||||
});
|
||||
expect(firstLineCells(next, source.path)).toBe(priorCells);
|
||||
expect(next.rows[900]).toMatchObject({
|
||||
kind: "line",
|
||||
cells: [{ fragments: [{ text: source.hunks[0]!.lines[900]!.content }] }],
|
||||
});
|
||||
expect(advancedCharacters).toBeLessThan(600);
|
||||
expect(next.height).toBe(eager.height);
|
||||
advancedCharacters = 0;
|
||||
buildDiffDocumentModel({
|
||||
...base,
|
||||
reuseFrom: [next],
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
|
||||
});
|
||||
expect(advancedCharacters).toBe(0);
|
||||
});
|
||||
|
||||
it("does not imperatively scroll for a no-op relayout", () => {
|
||||
expect(shouldApplyRelayoutScroll(320, 320)).toBe(false);
|
||||
expect(shouldApplyRelayoutScroll(320, 320.4)).toBe(false);
|
||||
|
||||
@@ -157,21 +157,26 @@ function appendReusableFileRows(candidate: {
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
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);
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
top += nextRow.height;
|
||||
}
|
||||
@@ -232,71 +237,45 @@ function appendNewFileRows(candidate: {
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
lines.reduce((height, line) => height + line.height, 0) +
|
||||
DIFF_BODY_BORDER_HEIGHT;
|
||||
const measureWidth =
|
||||
!candidate.input.wrapLines &&
|
||||
intersectsMaterializationWindow(candidate.input, candidate.fileTop, fileBottom);
|
||||
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"];
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
const columnWidth = candidate.input.viewportWidth / cells.length;
|
||||
if (measureWidth) {
|
||||
for (const cell of cells) {
|
||||
if (!cell) continue;
|
||||
const width =
|
||||
cell.fragments.length > 0
|
||||
? measureCellWidth(cell)
|
||||
: candidate.input.measureText.measure(displayLineText(cell.content));
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + width - columnWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
const height = textHeight + line.reviewHeight;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -305,13 +284,46 @@ function appendMeasuredFileRows(
|
||||
top,
|
||||
height,
|
||||
cells,
|
||||
reviewHeight,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
|
||||
}
|
||||
|
||||
function materializeCells(
|
||||
cells: DiffLineRow["cells"],
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
gutterWidth: number,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
): DiffLineRow["cells"] {
|
||||
const availableWidth = Math.max(
|
||||
input.measureText.measure("M"),
|
||||
input.viewportWidth / cells.length - gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
return cells.map((cell) => {
|
||||
if (!cell || cell.fragments.length > 0) return cell;
|
||||
const sourceLine =
|
||||
file.hunks[cell.sourceIdentity.hunkIndex]!.lines[cell.sourceIdentity.lineIndex]!;
|
||||
return measureCell({
|
||||
source: {
|
||||
...cell,
|
||||
tokenText: cell.type === "header" ? [] : compactHighlightTokens(sourceLine.tokens ?? []),
|
||||
},
|
||||
availableWidth,
|
||||
input,
|
||||
});
|
||||
}) as DiffLineRow["cells"];
|
||||
}
|
||||
|
||||
function displayLineText(text: string): string {
|
||||
return text.includes("\t")
|
||||
? segmentGraphemes(text)
|
||||
.map((segment) => segment.text)
|
||||
.join("")
|
||||
: text;
|
||||
}
|
||||
|
||||
export function reviewGeometryKey(
|
||||
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
|
||||
): string {
|
||||
@@ -334,7 +346,11 @@ export function retainReusableModels(
|
||||
previous: readonly DiffDocumentModel[] | undefined,
|
||||
next: DiffDocumentModel,
|
||||
): DiffDocumentModel[] {
|
||||
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
||||
const sources = new Set(next.files.map((file) => file.file));
|
||||
const compatible = previous?.filter((model) =>
|
||||
model.files.every((file) => sources.has(file.file)),
|
||||
);
|
||||
const fullest = [...(compatible ?? []), next].reduce((best, candidate) =>
|
||||
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
|
||||
);
|
||||
return fullest === next ? [next] : [fullest, next];
|
||||
@@ -437,16 +453,17 @@ function intersectsMaterializationWindow(
|
||||
|
||||
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
|
||||
if (file.isCollapsed) return false;
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).some((row) => {
|
||||
if (row.kind !== "line") return true;
|
||||
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
|
||||
return row.cells.some((cell) => cell && cell.fragments.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function indexReusableModels(
|
||||
models: readonly DiffDocumentModel[] | undefined,
|
||||
): ReusableModelIndex[] {
|
||||
return (models ?? []).map((model) => {
|
||||
// The latest model includes text prepared by later scroll windows.
|
||||
return (models ?? []).toReversed().map((model) => {
|
||||
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
|
||||
const measuredFiles = new Set<DiffFileSection>();
|
||||
for (const file of model.files) {
|
||||
|
||||
@@ -110,7 +110,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(() => {
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
viewport.width,
|
||||
@@ -172,8 +171,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[family, props.palette, typography.lineHeight, typography.size],
|
||||
);
|
||||
const textLayout = useMemo(
|
||||
() => prepareNativeTextLayout(textLayoutStore, model),
|
||||
[model, textLayoutStore],
|
||||
() =>
|
||||
prepareNativeTextLayout(
|
||||
textLayoutStore,
|
||||
model,
|
||||
diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
),
|
||||
[model, textLayoutStore, fileWindowTop, viewport.height],
|
||||
);
|
||||
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
||||
const headerTextLayout = useMemo(
|
||||
@@ -386,11 +390,12 @@ function NativeCanvasFileHeader({
|
||||
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
|
||||
[file, paints, textLayout, viewportWidth],
|
||||
);
|
||||
const { top, bottom, headerHeight } = file;
|
||||
const stickyStyle = useAnimatedStyle(() => {
|
||||
const pinOffset = Math.max(0, scrollTop.value - file.top);
|
||||
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
|
||||
const pinOffset = Math.max(0, scrollTop.value - top);
|
||||
const maximumPinOffset = Math.max(0, bottom - headerHeight - top);
|
||||
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
|
||||
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
|
||||
}, [bottom, headerHeight, top, scrollTop]);
|
||||
const style = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("cached ASCII text metrics", () => {
|
||||
|
||||
expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]);
|
||||
expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]);
|
||||
expect(metrics.measure("abba")).toBe(40);
|
||||
expect(measured).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface PrimaryTextFace {
|
||||
}
|
||||
|
||||
export interface CachedAsciiTextMetrics {
|
||||
/** Whole-line width after checking glyph coverage and excluding shaping. */
|
||||
measure(text: string): number;
|
||||
hasEveryGlyph(text: string): boolean;
|
||||
measureAdvances(graphemes: readonly string[]): number[];
|
||||
}
|
||||
@@ -97,24 +99,39 @@ export function advancesFor(measurer: TextMeasurer): (graphemes: readonly string
|
||||
export function createAdditiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return additiveAdvances(cachedWidths(measure));
|
||||
}
|
||||
|
||||
function cachedWidths(measure: (text: string) => number): (text: string) => number {
|
||||
const widths = new Map<string, number>();
|
||||
return (text) => {
|
||||
let width = widths.get(text);
|
||||
if (width === undefined) {
|
||||
width = measure(text);
|
||||
widths.set(text, width);
|
||||
}
|
||||
return width;
|
||||
};
|
||||
}
|
||||
|
||||
function additiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return (graphemes) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
width = measure(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
advance += width;
|
||||
return advance;
|
||||
});
|
||||
return graphemes.map((grapheme) => (advance += measure(grapheme)));
|
||||
};
|
||||
}
|
||||
|
||||
export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics {
|
||||
const glyphCoverage = new Map<string, boolean>();
|
||||
const measure = cachedWidths((text) => primary.measure(text));
|
||||
return {
|
||||
measure(text) {
|
||||
let width = 0;
|
||||
for (const character of text) width += measure(character);
|
||||
return width;
|
||||
},
|
||||
hasEveryGlyph(text) {
|
||||
for (const character of text) {
|
||||
let hasGlyph = glyphCoverage.get(character);
|
||||
@@ -126,7 +143,7 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs
|
||||
}
|
||||
return true;
|
||||
},
|
||||
measureAdvances: createAdditiveAdvances((text) => primary.measure(text)),
|
||||
measureAdvances: additiveAdvances(measure),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
type SkFont,
|
||||
type SkParagraph,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import { fragmentTextForRange, visibleRowRange } from "./model";
|
||||
import { codeTextColor } from "./palette";
|
||||
import {
|
||||
createCachedAsciiTextMetrics,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createFallbackAwareTextMeasurer,
|
||||
requiresShaping,
|
||||
type CachedAsciiTextMetrics,
|
||||
} from "./text-measurement";
|
||||
@@ -86,7 +85,8 @@ export function shapeNativeHeaderText(input: {
|
||||
export interface NativeTextLayoutStore {
|
||||
font: SkFont;
|
||||
asciiMetrics: CachedAsciiTextMetrics;
|
||||
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
||||
paragraphsByCell: Map<DiffCell, Array<SkParagraph | null>>;
|
||||
textStyles: Map<string, ReturnType<typeof textStyle>>;
|
||||
ownedParagraphs: Set<SkParagraph>;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
@@ -97,6 +97,8 @@ export interface NativeTextLayoutStore {
|
||||
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
|
||||
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
|
||||
layout.ownedParagraphs.clear();
|
||||
layout.paragraphsByCell.clear();
|
||||
layout.textStyles.clear();
|
||||
layout.font.dispose();
|
||||
}
|
||||
|
||||
@@ -114,7 +116,8 @@ export function createNativeTextLayoutStore(input: {
|
||||
glyphIds: (text) => font.getGlyphIDs(text),
|
||||
measure: (text) => font.getTextWidth(text),
|
||||
}),
|
||||
paragraphsByCell: new WeakMap(),
|
||||
paragraphsByCell: new Map(),
|
||||
textStyles: new Map(),
|
||||
ownedParagraphs: new Set(),
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
@@ -126,30 +129,64 @@ export function createNativeTextLayoutStore(input: {
|
||||
export function prepareNativeTextLayout(
|
||||
store: NativeTextLayoutStore,
|
||||
model: DiffDocumentModel,
|
||||
window?: { top: number; height: number },
|
||||
): NativeTextLayout {
|
||||
const paragraphs = model.rows.map((row) => {
|
||||
if (row.kind !== "line") return [];
|
||||
return row.cells.map((cell) => {
|
||||
const range = window
|
||||
? visibleRowRange(model.rows, window.top, window.height)
|
||||
: { start: 0, end: model.rows.length };
|
||||
const retainedCells = new Set<DiffCell>();
|
||||
const retainedParagraphs = new Set<SkParagraph>();
|
||||
const paragraphs: NativeTextLayout["paragraphs"] = [];
|
||||
paragraphs.length = model.rows.length;
|
||||
for (let index = range.start; index < range.end; index++) {
|
||||
const row = model.rows[index]!;
|
||||
if (row.kind !== "line") continue;
|
||||
paragraphs[index] = row.cells.map((cell) => {
|
||||
if (!cell) return [];
|
||||
retainedCells.add(cell);
|
||||
const cached = store.paragraphsByCell.get(cell);
|
||||
if (cached) return cached;
|
||||
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) return null;
|
||||
const paragraph = createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
});
|
||||
const start = window ? Math.max(0, Math.floor((window.top - row.top) / model.lineHeight)) : 0;
|
||||
const end = window
|
||||
? Math.min(
|
||||
cell.fragments.length,
|
||||
Math.ceil((window.top + window.height - row.top) / model.lineHeight),
|
||||
)
|
||||
: cell.fragments.length;
|
||||
const cellParagraphs: Array<SkParagraph | null> = [];
|
||||
cellParagraphs.length = cell.fragments.length;
|
||||
for (let fragmentIndex = start; fragmentIndex < end; fragmentIndex++) {
|
||||
const fragment = cell.fragments[fragmentIndex]!;
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) {
|
||||
cellParagraphs[fragmentIndex] = null;
|
||||
continue;
|
||||
}
|
||||
const paragraph =
|
||||
cached?.[fragmentIndex] ??
|
||||
createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
textStyles: store.textStyles,
|
||||
});
|
||||
store.ownedParagraphs.add(paragraph);
|
||||
return paragraph;
|
||||
});
|
||||
retainedParagraphs.add(paragraph);
|
||||
cellParagraphs[fragmentIndex] = paragraph;
|
||||
}
|
||||
store.paragraphsByCell.set(cell, cellParagraphs);
|
||||
return cellParagraphs;
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const paragraph of store.ownedParagraphs) {
|
||||
if (retainedParagraphs.has(paragraph)) continue;
|
||||
paragraph.dispose();
|
||||
store.ownedParagraphs.delete(paragraph);
|
||||
}
|
||||
for (const cell of store.paragraphsByCell.keys()) {
|
||||
if (!retainedCells.has(cell)) store.paragraphsByCell.delete(cell);
|
||||
}
|
||||
return { font: store.font, paragraphs };
|
||||
}
|
||||
|
||||
@@ -164,23 +201,25 @@ export function createNativeTextMeasurer(input: {
|
||||
measure: (text: string) => font.getTextWidth(text),
|
||||
};
|
||||
const asciiMetrics = createCachedAsciiTextMetrics(primary);
|
||||
const fallbackAware = createFallbackAwareTextMeasurer({
|
||||
primary,
|
||||
measureWithSystemFallback(text) {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
},
|
||||
});
|
||||
const black = Skia.Color("black");
|
||||
const measureParagraph = (text: string) => {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: black,
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
};
|
||||
return {
|
||||
...fallbackAware,
|
||||
measure(text) {
|
||||
return requiresRetainedParagraph(text, asciiMetrics)
|
||||
? measureParagraph(text)
|
||||
: asciiMetrics.measure(text);
|
||||
},
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics),
|
||||
measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes),
|
||||
@@ -190,7 +229,7 @@ export function createNativeTextMeasurer(input: {
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
color: black,
|
||||
});
|
||||
let end = 0;
|
||||
const advances = graphemes.map((grapheme) => {
|
||||
@@ -219,23 +258,21 @@ function createFragmentParagraph(input: {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
textStyles: NativeTextLayoutStore["textStyles"];
|
||||
}): SkParagraph {
|
||||
const builder = Skia.ParagraphBuilder.Make(
|
||||
paragraphStyle(input.families, input.fontSize, input.lineHeight),
|
||||
);
|
||||
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||
const color = codeTextColor(input.cell, input.palette);
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, color))
|
||||
.addText(input.fragment.text)
|
||||
.pop();
|
||||
builder.pushStyle(retainedTextStyle(input, color)).addText(input.fragment.text).pop();
|
||||
} else {
|
||||
for (const run of input.cell.tokens) {
|
||||
const start = Math.max(input.fragment.start, run.start);
|
||||
const end = Math.min(input.fragment.end, run.end);
|
||||
if (end <= start) continue;
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, run.color))
|
||||
.pushStyle(retainedTextStyle(input, run.color))
|
||||
.addText(fragmentTextForRange(input.fragment, start, end))
|
||||
.pop();
|
||||
}
|
||||
@@ -281,6 +318,18 @@ function paragraphStyle(families: string[], fontSize: number, lineHeight: number
|
||||
};
|
||||
}
|
||||
|
||||
function retainedTextStyle(
|
||||
input: { families: string[]; fontSize: number; textStyles: NativeTextLayoutStore["textStyles"] },
|
||||
color: string,
|
||||
) {
|
||||
let style = input.textStyles.get(color);
|
||||
if (!style) {
|
||||
style = textStyle(input.families, input.fontSize, color);
|
||||
input.textStyles.set(color, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function textStyle(families: string[], fontSize: number, color: string) {
|
||||
return { fontFamilies: families, fontSize, color: Skia.Color(color) };
|
||||
}
|
||||
|
||||
@@ -78,6 +78,40 @@ function modelInput(
|
||||
}
|
||||
|
||||
describe("diff document workspace cache", () => {
|
||||
it("keeps text prepared in each visited window of the same file", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: `const value${index} = ${index};`,
|
||||
}));
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const input = modelInput([source], measureText);
|
||||
for (const top of [30, 300, 600]) {
|
||||
cache.buildModel({ ...input, materializationWindow: { top, height: 54 } });
|
||||
}
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, materializationWindow: { top: 300, height: 54 } });
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
const files = [diffFile(), { ...diffFile(), path: "b.ts" }];
|
||||
const first = cache.buildModel(modelInput(files, measureText));
|
||||
const replacement = structuredClone(files[0]!);
|
||||
replacement.hunks[0]!.lines[0]!.content = "changed text";
|
||||
const next = cache.buildModel(modelInput([replacement, files[1]!], measureText));
|
||||
const oldRow = first.rows[first.files[1]!.rowStart]!;
|
||||
const nextRow = next.rows[next.files[1]!.rowStart]!;
|
||||
expect(oldRow.kind).toBe("line");
|
||||
expect(nextRow.kind).toBe("line");
|
||||
if (oldRow.kind !== "line" || nextRow.kind !== "line") throw new Error("Missing file text");
|
||||
expect(nextRow.cells).toBe(oldRow.cells);
|
||||
expect(next.files[0]!.file).toBe(replacement);
|
||||
});
|
||||
|
||||
it("reuses measured rows until a model-building input changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const files = [diffFile()];
|
||||
@@ -115,7 +149,7 @@ describe("diff document workspace cache", () => {
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, files: [...files] });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds retained geometry variants for one diff payload", () => {
|
||||
@@ -152,7 +186,7 @@ describe("diff document workspace cache", () => {
|
||||
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const first = cache.buildModel(
|
||||
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
|
||||
modelInput(files, measureText, { materializationWindow: { top: 30, height: 20 } }),
|
||||
);
|
||||
const firstCalls = stats.calls;
|
||||
const secondFile = first.files[1]!;
|
||||
|
||||
@@ -13,6 +13,7 @@ const MAX_MODEL_VARIANTS = 4;
|
||||
const MAX_TYPOGRAPHY_RESOURCES = 4;
|
||||
|
||||
interface ModelVariant {
|
||||
measureText: TextMeasurer;
|
||||
key: string;
|
||||
exactKey: string;
|
||||
model: DiffDocumentModel;
|
||||
@@ -38,23 +39,29 @@ export interface DiffDocumentWorkspaceCache {
|
||||
}
|
||||
|
||||
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], ModelVariant[]>();
|
||||
let variants: ModelVariant[] = [];
|
||||
const typographyResources = new Map<string, DiffTypographyResource>();
|
||||
|
||||
return {
|
||||
buildModel(input) {
|
||||
const key = modelVariantKey(input);
|
||||
const exactKey = exactModelKey(input);
|
||||
const variants = variantsByFiles.get(input.files) ?? [];
|
||||
const variantIndex = variants.findIndex((candidate) => candidate.key === key);
|
||||
const variantIndex = variants.findIndex(
|
||||
(candidate) => candidate.key === key && candidate.measureText === input.measureText,
|
||||
);
|
||||
const variant = variantIndex === -1 ? undefined : variants[variantIndex];
|
||||
if (variant?.exactKey === exactKey) {
|
||||
if (
|
||||
variant?.exactKey === exactKey &&
|
||||
variant.model.files.length === input.files.length &&
|
||||
variant.model.files.every((file, index) => file.file === input.files[index])
|
||||
) {
|
||||
variants.splice(variantIndex, 1);
|
||||
variants.unshift(variant);
|
||||
return variant.model;
|
||||
}
|
||||
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
|
||||
const nextVariant = {
|
||||
measureText: input.measureText,
|
||||
key,
|
||||
exactKey,
|
||||
model,
|
||||
@@ -62,7 +69,7 @@ export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
};
|
||||
if (variantIndex !== -1) variants.splice(variantIndex, 1);
|
||||
variants.unshift(nextVariant);
|
||||
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
|
||||
variants = variants.slice(0, MAX_MODEL_VARIANTS);
|
||||
return model;
|
||||
},
|
||||
typography(input) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retainDiffFiles, shareCheckoutDiff, shareCommitFileDiff } from "./diff-sharing";
|
||||
|
||||
const file: ParsedDiffFile = {
|
||||
path: "a.ts",
|
||||
oldPath: "old-a.ts",
|
||||
status: "ok",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{
|
||||
type: "add",
|
||||
content: "const a",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " a", style: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exhaustive keys make a protocol-field addition require a sharing-policy test.
|
||||
const fileChanges = {
|
||||
path: (value) => {
|
||||
value.path = "b.ts";
|
||||
},
|
||||
oldPath: (value) => {
|
||||
delete value.oldPath;
|
||||
},
|
||||
status: (value) => {
|
||||
value.status = "binary";
|
||||
},
|
||||
isNew: (value) => {
|
||||
value.isNew = true;
|
||||
},
|
||||
isDeleted: (value) => {
|
||||
value.isDeleted = true;
|
||||
},
|
||||
additions: (value) => {
|
||||
value.additions = 2;
|
||||
},
|
||||
deletions: (value) => {
|
||||
value.deletions = 2;
|
||||
},
|
||||
hunks: (value) => {
|
||||
value.hunks = [];
|
||||
},
|
||||
} satisfies Record<keyof ParsedDiffFile, (value: ParsedDiffFile) => void>;
|
||||
type Hunk = ParsedDiffFile["hunks"][number];
|
||||
const hunkChanges = {
|
||||
oldStart: (value) => {
|
||||
value.oldStart = 2;
|
||||
},
|
||||
oldCount: (value) => {
|
||||
value.oldCount = 2;
|
||||
},
|
||||
newStart: (value) => {
|
||||
value.newStart = 2;
|
||||
},
|
||||
newCount: (value) => {
|
||||
value.newCount = 2;
|
||||
},
|
||||
lines: (value) => {
|
||||
value.lines = [];
|
||||
},
|
||||
} satisfies Record<keyof Hunk, (value: Hunk) => void>;
|
||||
type Line = Hunk["lines"][number];
|
||||
const lineChanges = {
|
||||
type: (value) => {
|
||||
value.type = "remove";
|
||||
},
|
||||
content: (value) => {
|
||||
value.content = "const b";
|
||||
},
|
||||
tokens: (value) => {
|
||||
delete value.tokens;
|
||||
},
|
||||
} satisfies Record<keyof Line, (value: Line) => void>;
|
||||
type Token = NonNullable<Line["tokens"]>[number];
|
||||
const tokenChanges = {
|
||||
text: (value) => {
|
||||
value.text = "class";
|
||||
},
|
||||
style: (value) => {
|
||||
value.style = null;
|
||||
},
|
||||
} satisfies Record<keyof Token, (value: Token) => void>;
|
||||
|
||||
describe("diff sharing", () => {
|
||||
it.each(Object.entries(fileChanges))("replaces a file when %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(hunkChanges))("replaces a file when hunk %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(lineChanges))("replaces a file when line %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!.lines[0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(tokenChanges))("replaces a file when token %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!.lines[0]!.tokens![0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it("retains moved files and removes missing files", () => {
|
||||
const second = { ...file, path: "b.ts" };
|
||||
const reordered = retainDiffFiles([file, second], structuredClone([second, file]));
|
||||
expect(reordered[0]).toBe(second);
|
||||
expect(reordered[1]).toBe(file);
|
||||
expect(retainDiffFiles(reordered, [structuredClone(file)])[0]).toBe(file);
|
||||
});
|
||||
it("updates response metadata without invalidating unchanged text", () => {
|
||||
const previous = { cwd: "/repo", files: [file], error: null, requestId: "a" };
|
||||
expect(shareCheckoutDiff(previous, structuredClone(previous))).toBe(previous);
|
||||
const next = shareCheckoutDiff(previous, { ...structuredClone(previous), requestId: "b" });
|
||||
expect(next.requestId).toBe("b");
|
||||
expect(next.files).toBe(previous.files);
|
||||
const rejected = {
|
||||
...previous,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Too large" },
|
||||
diffTooLarge: true,
|
||||
};
|
||||
expect(shareCheckoutDiff(next, rejected)).toEqual(rejected);
|
||||
});
|
||||
it("preserves cached commit bodies and publishes null transitions", () => {
|
||||
const previous = { file };
|
||||
expect(shareCommitFileDiff(previous, structuredClone(previous))).toBe(previous);
|
||||
expect(shareCommitFileDiff(previous, { file: null })).toEqual({ file: null });
|
||||
expect(shareCommitFileDiff({ file: null }, previous)).toBe(previous);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
previous: ParsedDiffFile[],
|
||||
incoming: ParsedDiffFile[],
|
||||
): ParsedDiffFile[] {
|
||||
if (previous === incoming) return previous;
|
||||
const byPath = new Map(previous.map((file) => [file.path, file]));
|
||||
let unchanged = previous.length === incoming.length;
|
||||
const files = incoming.map((file, index) => {
|
||||
const old = byPath.get(file.path);
|
||||
const retained = old && equalFile(old, file) ? old : file;
|
||||
unchanged &&= retained === previous[index];
|
||||
return retained;
|
||||
});
|
||||
return unchanged ? previous : files;
|
||||
}
|
||||
|
||||
export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPayload {
|
||||
const next = incoming as DiffPayload;
|
||||
const old = previous as DiffPayload | undefined;
|
||||
if (!old || old === next) return next;
|
||||
const files = retainDiffFiles(old.files, next.files);
|
||||
const error = replaceEqualDeep(old.error, next.error);
|
||||
return files === old.files &&
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
}
|
||||
|
||||
export function shareCommitFileDiff(
|
||||
previous: unknown,
|
||||
incoming: unknown,
|
||||
): { file: ParsedDiffFile | null } {
|
||||
const next = incoming as { file: ParsedDiffFile | null };
|
||||
const old = previous as typeof next | undefined;
|
||||
return old &&
|
||||
(old.file === next.file || (old.file && next.file && equalFile(old.file, next.file)))
|
||||
? old
|
||||
: next;
|
||||
}
|
||||
|
||||
function equalFile(left: ParsedDiffFile, right: ParsedDiffFile): boolean {
|
||||
if (left === right) return true;
|
||||
if (
|
||||
left.path !== right.path ||
|
||||
left.oldPath !== right.oldPath ||
|
||||
left.status !== right.status ||
|
||||
left.isNew !== right.isNew ||
|
||||
left.isDeleted !== right.isDeleted ||
|
||||
left.additions !== right.additions ||
|
||||
left.deletions !== right.deletions ||
|
||||
left.hunks.length !== right.hunks.length
|
||||
)
|
||||
return false;
|
||||
for (let h = 0; h < left.hunks.length; h++) {
|
||||
const a = left.hunks[h]!;
|
||||
const b = right.hunks[h]!;
|
||||
if (a === b) continue;
|
||||
if (
|
||||
a.oldStart !== b.oldStart ||
|
||||
a.oldCount !== b.oldCount ||
|
||||
a.newStart !== b.newStart ||
|
||||
a.newCount !== b.newCount ||
|
||||
a.lines.length !== b.lines.length
|
||||
)
|
||||
return false;
|
||||
if (!equalLines(a.lines, b.lines)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalLines(
|
||||
left: ParsedDiffFile["hunks"][number]["lines"],
|
||||
right: ParsedDiffFile["hunks"][number]["lines"],
|
||||
): boolean {
|
||||
for (let l = 0; l < left.length; l++) {
|
||||
const x = left[l]!;
|
||||
const y = right[l]!;
|
||||
if (x === y) continue;
|
||||
if (x.type !== y.type || x.content !== y.content) return false;
|
||||
if (x.tokens === y.tokens) continue;
|
||||
if (!x.tokens || !y.tokens || x.tokens.length !== y.tokens.length) return false;
|
||||
for (let t = 0; t < x.tokens.length; t++) {
|
||||
const p = x.tokens[t]!;
|
||||
const q = y.tokens[t]!;
|
||||
if (p.text !== q.text || p.style !== q.style) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
|
||||
import { commitFileDiffQueryOptions } from "./commit-file-diff-query";
|
||||
import { useCheckoutCommitsQuery } from "@/git/use-commits-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -79,18 +79,16 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles
|
||||
Boolean(client) &&
|
||||
isConnected;
|
||||
const fileDiffResults = useFetchQueries(
|
||||
commitFiles.map((file) => ({
|
||||
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
|
||||
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.getCommitFileDiff(cwd, sha, file.path);
|
||||
},
|
||||
enabled: fileDiffsEnabled,
|
||||
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
dataShape: "value" as const,
|
||||
})),
|
||||
commitFiles.map((file) =>
|
||||
commitFileDiffQueryOptions({
|
||||
serverId,
|
||||
cwd,
|
||||
sha,
|
||||
path: file.path,
|
||||
client,
|
||||
enabled: fileDiffsEnabled,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading";
|
||||
const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { shareCheckoutDiff } from "./diff-sharing";
|
||||
import { useMemo } from "react";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
@@ -74,6 +75,7 @@ export function useCheckoutDiffQuery({
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
structuralSharing: shareCheckoutDiff,
|
||||
enabled: routeEnabled,
|
||||
pushEvent: "checkout_diff_update",
|
||||
meta: checkoutDiffPushRoute({
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export interface ClearWorkspaceAttentionController {
|
||||
hasClearableAttention: boolean;
|
||||
clearAttention: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useClearWorkspaceAttention({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): ClearWorkspaceAttentionController {
|
||||
const hasClearableAttention = useSessionStore((state) => {
|
||||
const workspace = state.sessions[serverId]?.workspaces.get(workspaceId);
|
||||
return workspace?.status === "attention" || workspace?.status === "failed";
|
||||
});
|
||||
|
||||
const clearAttention = useCallback(async () => {
|
||||
if (!hasClearableAttention) {
|
||||
return;
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await client.clearWorkspaceAttention(workspaceId);
|
||||
}, [hasClearableAttention, serverId, workspaceId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ hasClearableAttention, clearAttention }),
|
||||
[clearAttention, hasClearableAttention],
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export function useProvidersSnapshot(
|
||||
|
||||
const snapshotQuery = useReplicaQuery({
|
||||
queryKey,
|
||||
// The content-hash cache already owns catalog and model identity.
|
||||
structuralSharing: false,
|
||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -149,6 +151,7 @@ export function prefetchProvidersSnapshot(
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: Infinity,
|
||||
structuralSharing: false,
|
||||
queryFn: ({ signal }) =>
|
||||
fetchProvidersSnapshot({ client, serverId, cwd, queryClient: singletonQueryClient, signal }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { markWorkspaceUnread } from "@/workspace/mark-unread";
|
||||
|
||||
export interface WorkspaceReadStateController {
|
||||
hasClearableAttention: boolean;
|
||||
canMarkUnread: boolean;
|
||||
clearAttention: () => Promise<void>;
|
||||
markUnread: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaceReadState({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): WorkspaceReadStateController {
|
||||
const status = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.status);
|
||||
const supportsMarkUnread = useHostFeature(serverId, "workspaceMarkUnread");
|
||||
const hasClearableAttention = status === "attention" || status === "failed";
|
||||
const canMarkUnread = supportsMarkUnread && status === "done";
|
||||
|
||||
const clearAttention = useCallback(async () => {
|
||||
if (!hasClearableAttention) {
|
||||
return;
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await client.clearWorkspaceAttention(workspaceId);
|
||||
}, [hasClearableAttention, serverId, workspaceId]);
|
||||
|
||||
const markUnread = useCallback(async () => {
|
||||
if (!canMarkUnread) {
|
||||
return;
|
||||
}
|
||||
await markWorkspaceUnread(serverId, workspaceId);
|
||||
}, [canMarkUnread, serverId, workspaceId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ hasClearableAttention, canMarkUnread, clearAttention, markUnread }),
|
||||
[canMarkUnread, clearAttention, hasClearableAttention, markUnread],
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,11 @@ export function HostBadge({ badge }: { badge: HostBadgeModel }) {
|
||||
testID={`host-badge-${badge.serverId}`}
|
||||
accessibilityLabel={badge.label}
|
||||
>
|
||||
<ThemedServer size={HOST_BADGE_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[badge.color]} />
|
||||
<ThemedServer
|
||||
size={HOST_BADGE_ICON_SIZE}
|
||||
style={styles.icon}
|
||||
uniProps={HOST_ICON_MAPPINGS[badge.color]}
|
||||
/>
|
||||
{badge.showLabel ? (
|
||||
<Text style={[styles.label, labelColorStyle(badge.color)]} numberOfLines={1}>
|
||||
{badge.label}
|
||||
@@ -92,13 +96,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
minWidth: HOST_BADGE_ICON_SIZE,
|
||||
// Outweighs a sibling's `flexShrink: 1` by enough that the badge is effectively fully
|
||||
// squeezed before that sibling gives up its first pixel. An equal factor would split the
|
||||
// loss in proportion to length, which hands the most space to the longest hostname —
|
||||
// exactly backwards. The icon has a fixed width, so the badge never disappears outright.
|
||||
flexShrink: 100,
|
||||
},
|
||||
icon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "بحث",
|
||||
placeholder: "بحث في اللوحة",
|
||||
close: "إغلاق البحث",
|
||||
matches: "نتائج البحث",
|
||||
previous: "التطابق السابق",
|
||||
next: "التطابق التالي",
|
||||
toggleReplace: "إظهار الاستبدال",
|
||||
replaceWith: "استبدال بـ",
|
||||
replace: "استبدال",
|
||||
replaceAll: "استبدال الكل",
|
||||
noMatches: "لا توجد تطابقات",
|
||||
position: "{{current}} من {{total}}",
|
||||
total: "{{total}} تطابقات",
|
||||
},
|
||||
common: {
|
||||
back: "خلف",
|
||||
loading: "تحميل...",
|
||||
@@ -58,6 +73,18 @@ export const ar: TranslationResources = {
|
||||
close: "إغلاق القائمة",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "البحث في محتوى الملفات",
|
||||
content: "محتوى الملفات",
|
||||
contentPlaceholder: "البحث في الملفات المحفوظة...",
|
||||
contentUnavailable: "مساحة العمل غير متاحة",
|
||||
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
|
||||
contentBack: "العودة إلى النتائج",
|
||||
contentOpen: "فتح",
|
||||
contentLoadingPreview: "جارٍ تحميل المعاينة...",
|
||||
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
|
||||
contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}",
|
||||
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
|
||||
|
||||
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
|
||||
filePlaceholder: "البحث في الملفات...",
|
||||
searchingFiles: "جارٍ البحث في الملفات...",
|
||||
@@ -1042,6 +1069,16 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث هذا المضيف لإدارة التسميات.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "ما الجديد",
|
||||
installed: "مثبّت",
|
||||
showMore: "عرض المزيد",
|
||||
openWebsite: "سجل التغييرات الكامل",
|
||||
error: {
|
||||
title: "تعذّر تحميل سجل التغييرات",
|
||||
description: "تحقق من اتصالك وحاول مرة أخرى.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "تفضيلات العرض",
|
||||
@@ -2067,6 +2104,7 @@ export const ar: TranslationResources = {
|
||||
about: {
|
||||
title: "عن",
|
||||
appVersion: "نسخة التطبيق",
|
||||
whatsNewHint: "ملاحظات الإصدار لكل نسخة",
|
||||
thisDevice: "هذا الجهاز",
|
||||
connectedHosts: "المضيفين المتصلين",
|
||||
offline: "غير متصل",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user