mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 09:12:44 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e7a448f0 | ||
|
|
4574142314 | ||
|
|
4673ab1903 | ||
|
|
76b32544c6 | ||
|
|
bdae614e95 | ||
|
|
bad132884d |
@@ -41,6 +41,7 @@ server:
|
||||
desktop:
|
||||
- "packages/desktop/**"
|
||||
- "packages/app/src/desktop/**"
|
||||
- "packages/app/src/**/*.electron.{ts,tsx}"
|
||||
- "packages/server/src/server/browser-tools/**"
|
||||
- "packages/app/e2e/support/**"
|
||||
- "packages/app/*config.{cjs,js,ts}"
|
||||
|
||||
@@ -1,5 +1,70 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.0-beta.1 - 2026-10-22
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
将你的 daemon 连接到另一台机器,然后让 Agent 把工作委派过去:
|
||||
|
||||
```sh
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
paseo host add 'relay://...' --label laptop
|
||||
```
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
|
||||
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
|
||||
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
|
||||
|
||||
### 改进
|
||||
|
||||
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
|
||||
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
|
||||
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
|
||||
- iOS 和 Android 上隐藏面板中的状态环动画现在会暂停 ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
|
||||
- 应用启动时只加载所选语言的翻译,减少了启动工作量 ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- 工作区布局未变化时不再重复保存 ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### 插件
|
||||
|
||||
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
|
||||
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
|
||||
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
|
||||
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
|
||||
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
- 修复了 `paseo agent archive` 对前 200 个之后的 Agent 报告 `AGENT_NOT_FOUND` 的问题 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
|
||||
- 修复了读取历史记录后已归档的 Agent 重新出现在 MCP `list_agents` 中的问题 ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
|
||||
- 修复了使用 `notifyOnFinish` 创建的分离 Agent 从不通知其创建者的问题 ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
|
||||
- 修复了 OpenCode Agent 被告知 `notifyOnFinish` 默认关闭的问题 ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
|
||||
- 修复了恢复被拒绝时 Pi 子 Agent 行仍显示为运行中的问题 ([#7369](https://github.com/getpaseo/paseo/pull/7369))
|
||||
- 修复了 Android 源码构建下载第二个 NDK 的问题 ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- 修复了 Nix 包将许可证报告为 AGPL 而不是 Apache-2.0 的问题 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
|
||||
|
||||
## 0.12.0 - 2026-10-19
|
||||
|
||||
后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及超过 120 项修复。
|
||||
|
||||
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
|
||||
+1
-3
@@ -317,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a
|
||||
session is created, resumed, imported, or reloaded, so configuration changes affect the next
|
||||
session rather than an already-running one.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen).
|
||||
|
||||
### Git process limits
|
||||
|
||||
@@ -353,8 +353,6 @@ Environment variables override `config.json`:
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
+16
-9
@@ -104,17 +104,24 @@ Do not collapse this topology with a catch-all, `getId`, or
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
Notifications and agent URLs name a host and agent. Both use
|
||||
`navigateToAgent()`, which reads the agent's current workspace from the store
|
||||
or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
|
||||
the named host, demands its directory, and waits for workspace and layout
|
||||
hydration before opening the tab. Keep the stable agent URL free of a workspace
|
||||
hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
|
||||
constraints.
|
||||
|
||||
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||
directly to the workspace with the agent open intent.
|
||||
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||
the agent's workspace from the host, and then opens the agent there.
|
||||
When a workspace target cannot open yet, `navigateToWorkspace()` records a
|
||||
pending open in the layout model under that host and workspace key. The owning
|
||||
workspace screen consumes it once after hydration. Archived-workspace recovery
|
||||
reads the same pending target, so startup redirects cannot discard it and a
|
||||
retained screen cannot consume another workspace's request.
|
||||
|
||||
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||
Legacy `?open=` links seed this model state at the matched route boundary and
|
||||
then lose the query parameter. Internal navigation does not use the query as a
|
||||
handoff. Read matched route params locally; global search params let retained
|
||||
workspace screens observe requests for other workspaces.
|
||||
|
||||
## Params
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Notifications
|
||||
|
||||
Use `notification-open` for OS notification taps. It owns delivery, parsing,
|
||||
process-local deduplication, startup readiness, and native acknowledgement.
|
||||
Keep agent and workspace navigation in their existing navigation modules.
|
||||
Import the pure API from `@/notification-open` and the app-shell component from
|
||||
`@/notification-open/react`; senders must not load the React routing runtime.
|
||||
|
||||
Agent payloads name a host and agent. Ignore their workspace hint when opening:
|
||||
the agent can move after a notification is sent. Use `navigateToAgent`, which
|
||||
uses the current store record or the agent route to resolve the workspace from
|
||||
the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
|
||||
|
||||
See [agent targets and deferred opens](expo-router.md#agent-targets) for route
|
||||
ownership and workspace recovery. Notification generation, presence, and push
|
||||
eligibility remain daemon concerns.
|
||||
|
||||
## Native delivery
|
||||
|
||||
Keep the router instance at module scope. Android's `singleTask` Activity can
|
||||
finish on Back while its JS context remains alive. A React ref is lost when the
|
||||
root remounts; process-local handled IDs must survive that remount.
|
||||
|
||||
Clear `lastNotificationResponse` after dispatch. Reading a launch response does
|
||||
not acknowledge it. Without clearing it, a later Activity launch can reopen the
|
||||
old agent.
|
||||
|
||||
With expo-notifications 57, Android also replays its pending response list to
|
||||
new native listeners. Clearing the last response does not drain that list.
|
||||
Deduplication covers listener registration within one JS context. A development
|
||||
JS reload creates another context and can replay a cold-launch response; do not
|
||||
add persisted handled IDs to compensate. Recheck this limitation when upgrading
|
||||
expo-notifications.
|
||||
|
||||
iOS drains pending responses when its delegate first handles them. Push data
|
||||
read by Expo arrives under `userInfo.body`; simulator payloads must match:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
|
||||
"body": {
|
||||
"serverId": "host",
|
||||
"workspaceId": "workspace",
|
||||
"agentId": "agent",
|
||||
"reason": "finished"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
|
||||
An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
|
||||
|
||||
Foreground pushes remain in Notification Center without a banner or sound.
|
||||
This preserves notifications sent with stale presence information.
|
||||
|
||||
## Startup and verification
|
||||
|
||||
Wait for the root navigator, host registry, remembered selection, and startup
|
||||
redirects before handing off a tap. A compact agent route must demand the host's
|
||||
directory itself; its sidebar can be closed for the entire cold launch.
|
||||
|
||||
Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
|
||||
and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
|
||||
workspace's saved layout, since a foreign pinned tab survives navigation.
|
||||
Use the daemon's payload shape on both iOS and Android; a browser event alone
|
||||
does not test native replay or acknowledgement.
|
||||
|
||||
Existing foreign pins belong to the reconciliation fix in
|
||||
[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
|
||||
selection is tracked separately in
|
||||
[#6875](https://github.com/getpaseo/paseo/issues/6875).
|
||||
+5
-3
@@ -566,9 +566,11 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
|
||||
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
|
||||
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
|
||||
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
|
||||
and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML
|
||||
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
|
||||
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
|
||||
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
|
||||
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
|
||||
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
|
||||
make that panel faster instead. Source highlights a large file on native in idle-time slices,
|
||||
the lines around the opening line first.
|
||||
|
||||
+4
-4
@@ -538,8 +538,8 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release
|
||||
|
||||
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
|
||||
|
||||
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
|
||||
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
|
||||
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
|
||||
## Changelog ownership
|
||||
@@ -654,7 +654,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
@@ -677,7 +677,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
|
||||
@@ -182,7 +182,8 @@ test.describe("Workspace file change conflicts", () => {
|
||||
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
||||
|
||||
await unlink(filePath);
|
||||
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
|
||||
await expectDeletedFileNotice(page);
|
||||
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
||||
await writeFile(filePath, "# After\n", "utf8");
|
||||
|
||||
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
@@ -55,10 +56,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
function htmlPreview(page: Page) {
|
||||
return {
|
||||
host: page.getByTestId("file-html-preview"),
|
||||
@@ -66,13 +63,32 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Switches the file tab to one of its panels from the bar's switcher. */
|
||||
async function selectFilePanel(page: Page, title: string): Promise<void> {
|
||||
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
exact: true,
|
||||
});
|
||||
await option.click();
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function expectAbove(upper: Locator, lower: Locator): Promise<void> {
|
||||
const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]);
|
||||
expect(upperBox).not.toBeNull();
|
||||
expect(lowerBox).not.toBeNull();
|
||||
expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y);
|
||||
}
|
||||
|
||||
/** How many times the app has read the file's contents from the daemon. */
|
||||
function fileReadCount(
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
fileName: string,
|
||||
): number {
|
||||
return gate
|
||||
.getClientRequests("file_explorer_request")
|
||||
.filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length;
|
||||
}
|
||||
|
||||
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||
const image = page.getByRole("button", { name, exact: true });
|
||||
await expect(image).toBeVisible();
|
||||
@@ -482,9 +498,9 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = filePanelSwitcher(page);
|
||||
const modeControl = page.getByTestId("file-preview-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
@@ -546,7 +562,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
|
||||
const markdownScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
@@ -582,6 +598,11 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||
await expect(page.getByTestId("file-preview-mode")).toHaveCount(0);
|
||||
await expectAbove(
|
||||
page.getByTestId("file-panel-bar").filter({ visible: true }),
|
||||
page.getByTestId("file-source-editor").filter({ visible: true }),
|
||||
);
|
||||
|
||||
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||
@@ -678,11 +699,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while switching between Markdown and Source", async ({
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
@@ -692,17 +714,30 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
|
||||
await expect(toolbar.getByLabel(/^File size/)).toBeVisible();
|
||||
await expect(toolbar.getByLabel("2 lines")).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await selectFilePanel(page, "Source");
|
||||
// Source shows the same read in the editor; the file is not read again.
|
||||
const reads = fileReadCount(gate, "notes.md");
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(editor(page)).toContainText("# Updated heading");
|
||||
await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible();
|
||||
expect(fileReadCount(gate, "notes.md")).toBe(reads);
|
||||
|
||||
// Preview shows the edits made in Source.
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await selectFilePanel(page, "Markdown");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0);
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
@@ -784,10 +819,10 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFilePanel(page, "HTML");
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) {
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectFileTabOpen(page, "README.md");
|
||||
await page
|
||||
.getByTestId("file-panel-switcher")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Markdown", exact: true })
|
||||
.getByTestId("file-panel-bar")
|
||||
.getByRole("button", { name: "Preview", exact: true })
|
||||
.click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
}
|
||||
|
||||
@@ -71,10 +71,6 @@ async function captureWideAndCompact(
|
||||
await ready();
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
/** Turns on vim keybindings without discarding settings the test writes later. */
|
||||
async function enableVimKeybindings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
@@ -94,16 +90,19 @@ async function replaceMarkdown(page: Page, text: string) {
|
||||
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
|
||||
}
|
||||
|
||||
async function selectPanel(page: Page, title: string) {
|
||||
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
|
||||
async function openWithFromTabMenu(page: Page, name: string, option: string) {
|
||||
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open with…" }).hover();
|
||||
await page.getByRole("menuitem", { name: option }).click();
|
||||
}
|
||||
|
||||
async function expectPanels(page: Page, titles: string[]) {
|
||||
for (const title of titles) {
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source") {
|
||||
const option = page
|
||||
.getByTestId("file-preview-mode")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: view, exact: true });
|
||||
await option.click();
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
|
||||
@@ -126,9 +125,6 @@ async function expectMarkdownEditor(page: Page, text: string) {
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(text);
|
||||
await expect(editor).toBeInViewport({ ratio: 1 });
|
||||
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
|
||||
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
|
||||
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
|
||||
@@ -197,7 +193,7 @@ test("directory examples mount the PDF frame and write Markdown through the file
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("radio")).toHaveCount(2);
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
|
||||
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
|
||||
// both pickers are mounted for a moment after a resize.
|
||||
@@ -220,32 +216,23 @@ test("directory examples mount the PDF frame and write Markdown through the file
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
|
||||
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
});
|
||||
|
||||
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
|
||||
await selectPanel(page, "Source");
|
||||
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
|
||||
// cursor and vim mode in the panel's toolbar.
|
||||
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
|
||||
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
|
||||
await selectFileView(page, "Source");
|
||||
const source = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
const bar = page.getByTestId("file-source-status").filter({ visible: true });
|
||||
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-source-wide");
|
||||
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
await filePanelSwitcher(page).click();
|
||||
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Source");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
|
||||
await capture(page, testInfo, "markdown-example-source-compact");
|
||||
await page.setViewportSize(WIDE);
|
||||
await selectPanel(page, "Rich Markdown");
|
||||
await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
|
||||
"# External update",
|
||||
@@ -266,7 +253,6 @@ test("directory examples mount the PDF frame and write Markdown through the file
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectMarkdownEditor(page, "Remembered panel");
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
} finally {
|
||||
await client.removePlugin("pdf-panel");
|
||||
await client.removePlugin("markdown-panel");
|
||||
|
||||
@@ -30,17 +30,26 @@ async function openWithFromTabMenu(page: Page, name: string, option: string) {
|
||||
await page.getByRole("menuitem", { name: option }).click();
|
||||
}
|
||||
|
||||
async function toggleAlwaysUseFromFileBar(page: Page) {
|
||||
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
|
||||
async function toggleAlwaysUseFromTabMenu(page: Page, name: string) {
|
||||
await openWithFromTabMenu(page, name, "Always use for .md");
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
/** The built-in panel's toolbar control between its Preview and Source views. */
|
||||
function viewControl(page: Page) {
|
||||
return page.getByTestId("file-preview-mode").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectSwitcherPanels(page: Page, titles: string[]) {
|
||||
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source") {
|
||||
const option = viewControl(page).getByRole("button", { name: view, exact: true });
|
||||
await option.click();
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function expectNoOpenWith(page: Page, name: string) {
|
||||
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
@@ -49,7 +58,7 @@ async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
|
||||
test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -62,19 +71,20 @@ test("opens Markdown with the built-in panel and Source beside it without asking
|
||||
await openFileExplorer(page);
|
||||
await openMarkdown(page, "notes.md");
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
await expectSwitcherPanels(page, ["Markdown", "Source"]);
|
||||
await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]);
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await expectNoOpenWith(page, "notes.md");
|
||||
await capture(page, testInfo, "file-panel-builtins-wide");
|
||||
|
||||
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
|
||||
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
|
||||
await selectFileView(page, "Preview");
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
|
||||
viewControl(page).getByRole("button", { name: "Source", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await capture(page, testInfo, "file-panel-builtins-compact");
|
||||
@@ -107,6 +117,7 @@ test("asks once which panel opens Markdown and remembers it when told to", async
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
|
||||
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
|
||||
await expect(page.getByRole("radio")).toHaveCount(2);
|
||||
await expectBuiltinMarkdown(page, "first heading");
|
||||
await capture(page, testInfo, "file-panel-picker-wide");
|
||||
|
||||
@@ -118,8 +129,8 @@ test("asks once which panel opens Markdown and remembers it when told to", async
|
||||
await expectPluginMarkdown(page, "second.md");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
|
||||
// Forgetting from the file bar keeps the tab on the plugin.
|
||||
await toggleAlwaysUseFromFileBar(page);
|
||||
// Forgetting from the tab menu keeps the tab on the plugin.
|
||||
await toggleAlwaysUseFromTabMenu(page, "second.md");
|
||||
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
|
||||
await expectPluginMarkdown(page, "second.md");
|
||||
|
||||
@@ -130,10 +141,14 @@ test("asks once which panel opens Markdown and remembers it when told to", async
|
||||
await expectPluginMarkdown(page, "third.md");
|
||||
expect(await storedFilePanelAssociations(page)).toEqual({});
|
||||
|
||||
// "Open with…" switches the open tab back to the built-in in place.
|
||||
// "Open with…" switches the open tab back to the built-in in place, with its own views.
|
||||
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
|
||||
await expectBuiltinMarkdown(page, "third heading");
|
||||
await expectFileTabOpen(page, "third.md");
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
|
||||
await selectFileView(page, "Preview");
|
||||
await expectBuiltinMarkdown(page, "third heading");
|
||||
|
||||
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
|
||||
await openMarkdown(page, "fourth.md");
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
BUILTIN_MARKDOWN_OPTION,
|
||||
MARKDOWN_PANEL_ID,
|
||||
PLUGIN_MARKDOWN_OPTION,
|
||||
SOURCE_OPTION,
|
||||
expectPluginMarkdown,
|
||||
filePanelPicker,
|
||||
installMarkdownPanelPlugin,
|
||||
@@ -18,6 +17,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
const ASK_EVERY_TIME = "Ask every time";
|
||||
const SOURCE_OPTION = "Source — Paseo";
|
||||
|
||||
function markdownRow(page: Page) {
|
||||
return page.getByTestId("file-panel-settings-row-ext:md");
|
||||
@@ -69,7 +69,7 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
|
||||
await markdownDropdown(page, ASK_EVERY_TIME).click();
|
||||
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0);
|
||||
await expectMenuOpen(page);
|
||||
await capture(page, testInfo, "file-panel-settings-wide-menu");
|
||||
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
|
||||
@@ -94,11 +94,11 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
|
||||
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
|
||||
await capture(page, testInfo, "file-panel-settings-compact");
|
||||
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expectMenuOpen(page);
|
||||
await capture(page, testInfo, "file-panel-settings-compact-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
|
||||
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden();
|
||||
|
||||
// Ask again forgets the choice; the next open asks.
|
||||
await askAgain(page).click();
|
||||
|
||||
@@ -91,9 +91,13 @@ async function associateMarkdownWithPlugin(page: Page) {
|
||||
async function editMarkdownThroughPlugin(page: Page) {
|
||||
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
|
||||
}
|
||||
async function selectSource(page: Page) {
|
||||
/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */
|
||||
async function openBuiltinMarkdownSource(page: Page, name: string) {
|
||||
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open with…" }).hover();
|
||||
await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click();
|
||||
await page
|
||||
.getByTestId("file-panel-switcher")
|
||||
.getByTestId("file-preview-mode")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Source", exact: true })
|
||||
.click();
|
||||
@@ -210,7 +214,7 @@ test("directory plugins render PDF bytes and write Markdown through the file han
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect.poll(() => closePrompt).toContain("discard");
|
||||
|
||||
await selectSource(page);
|
||||
await openBuiltinMarkdownSource(page, "notes.md");
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
|
||||
).toHaveText("# Changed outside");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { expectOpenTabCount } from "../support/helpers/sheet";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
@@ -227,3 +228,45 @@ test("manual unread survives leaving the current workspace on compact layout", a
|
||||
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
|
||||
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
const route = page.url();
|
||||
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
|
||||
await page.goto("about:blank");
|
||||
await workspace.client.sendAgentMessage(
|
||||
workspace.agentId,
|
||||
"A turn completed while the app was closed.",
|
||||
);
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
// Reopen the ordinary workspace route, without an agent open intent that would repair focus.
|
||||
await page.goto(route);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectRestoredCompactChat(page: Page) {
|
||||
await expectOpenTabCount(page, 1);
|
||||
await expect(
|
||||
page.getByText("A turn completed while the app was closed.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await test.step("open a finished chat with one main tab", async () => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectOpenTabCount(page, 1);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
});
|
||||
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
|
||||
await openMobileAgentSidebar(page);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
});
|
||||
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
|
||||
await expectRestoredCompactChat(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import { clickNewChat } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
||||
await closeButton.first().click();
|
||||
}
|
||||
|
||||
async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
|
||||
await page.evaluate((url) => {
|
||||
window.history.pushState(null, "", url);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}, route);
|
||||
await expectAppRoute(page, route.split("?")[0]);
|
||||
}
|
||||
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||
test("keeps an offline notification on the agent route until the host connects", async ({
|
||||
page,
|
||||
}) => {
|
||||
const target = {
|
||||
serverId: "notification-offline-host",
|
||||
workspaceId: "notification-workspace",
|
||||
@@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => {
|
||||
);
|
||||
}, target);
|
||||
|
||||
await expectAppRoute(
|
||||
page,
|
||||
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||
await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
|
||||
await expect(page.getByText(/Notification Host/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
|
||||
try {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Cold notification agent",
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
|
||||
@@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
@@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(secondDeckEntry).toBeHidden();
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await expectAgentAbsentFromWorkspaceLayout(page, {
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
await openWorkspaceTabUrl(
|
||||
page,
|
||||
buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
|
||||
);
|
||||
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||
await expectAgentAbsentFromWorkspaceLayout(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
@@ -11,7 +11,6 @@ export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
|
||||
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
|
||||
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
|
||||
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
|
||||
export const SOURCE_OPTION = "Source — Paseo";
|
||||
|
||||
const SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
|
||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
|
||||
await page.goto(workspaceRoute);
|
||||
|
||||
// The workspace layout consumes `?open=...`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
// The route seeds the legacy URL target into the pending-open model, then
|
||||
// removes the query. Tab readiness below waits for that intent to be consumed.
|
||||
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
||||
// so we allow a generous timeout here.
|
||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
|
||||
@@ -86,7 +86,8 @@ export async function navigateToTerminal(
|
||||
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await terminalTab.click();
|
||||
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
|
||||
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
|
||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// Wait for loading overlay to disappear (terminal attached)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
||||
import {
|
||||
@@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
|
||||
JSON.stringify(stored),
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectAgentAbsentFromWorkspaceLayout(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
if (!raw) throw new Error("Workspace layout was not persisted");
|
||||
const stored = z
|
||||
.object({ state: WorkspaceLayoutPersistedStateSchema })
|
||||
.parse(JSON.parse(raw));
|
||||
const key = `${input.serverId}:${input.workspaceId}`;
|
||||
const layout = stored.state.layoutByWorkspace[key];
|
||||
const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
|
||||
// The persisted tree is validated above; inspect all panes including hidden ones.
|
||||
return {
|
||||
containsAgent: JSON.stringify(layout).includes(input.agentId),
|
||||
pinned: pins.includes(input.agentId),
|
||||
};
|
||||
})
|
||||
.toEqual({ containsAgent: false, pinned: false });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { LucideProvider } from "lucide-react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
@@ -51,11 +49,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
@@ -97,7 +91,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import {
|
||||
@@ -132,14 +125,9 @@ import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { PluginCatalogSync } from "@/plugins";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { NotificationOpenRouter } from "@/notification-open/react";
|
||||
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -159,114 +147,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
startupBlocker: { kind: "none" },
|
||||
});
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const workspaceId = target.workspaceId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && workspaceId && agentId) {
|
||||
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
router.navigate(buildNotificationRoute(data));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsElectronRuntime()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||
"notification-click",
|
||||
(payload: unknown) => {
|
||||
const data =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"data" in payload &&
|
||||
typeof (payload as { data?: unknown }).data === "object" &&
|
||||
(payload as { data?: unknown }).data !== null
|
||||
? (payload as { data: Record<string, unknown> }).data
|
||||
: undefined;
|
||||
openNotification(data);
|
||||
},
|
||||
);
|
||||
|
||||
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||
if (typeof unlisten !== "function") {
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
removeDesktopNotificationListener = unlisten;
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
event.preventDefault();
|
||||
openNotification(customEvent.detail?.data);
|
||||
};
|
||||
|
||||
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeDesktopNotificationListener?.();
|
||||
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||
};
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
// When the app is open, don't show OS banners.
|
||||
shouldShowAlert: false,
|
||||
shouldShowBanner: false,
|
||||
shouldShowList: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const openFromResponse = (response: Notifications.NotificationResponse) => {
|
||||
const identifier = response.notification.request.identifier;
|
||||
if (lastHandledIdRef.current === identifier) {
|
||||
return;
|
||||
}
|
||||
lastHandledIdRef.current = identifier;
|
||||
|
||||
const data = response.notification.request.content.data as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
openNotification(data);
|
||||
};
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||
|
||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (response) {
|
||||
openFromResponse(response);
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [openNotification]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const client = useHostRuntimeClient(daemon.serverId);
|
||||
|
||||
@@ -945,6 +825,9 @@ function WorkspaceRouteNavigationBridge() {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
useEffect(() => {
|
||||
if (getDesktopHost()) void ensureOsNotificationPermission();
|
||||
}, []);
|
||||
return (
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
@@ -959,10 +842,15 @@ function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationOpenBridge() {
|
||||
const { startupBlocker } = useHostRuntimeBootstrapState();
|
||||
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
|
||||
}
|
||||
|
||||
function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<NotificationOpenBridge />
|
||||
<SidebarCalloutProvider>
|
||||
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||
</SidebarCalloutProvider>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
@@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() {
|
||||
lookupQuery.isSuccess,
|
||||
shouldLookupAgent,
|
||||
]);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
// A compact app can open this route before its sidebar has demanded the directory.
|
||||
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
|
||||
}, [serverId]);
|
||||
const resolution = resolveAgentRoute({
|
||||
hasHydratedWorkspaces,
|
||||
hasHydratedLayout,
|
||||
serverId,
|
||||
agentId,
|
||||
cachedWorkspaceId: agentWorkspaceId,
|
||||
@@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() {
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
|
||||
return;
|
||||
}
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useNavigation } from "expo-router/react-navigation";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
areRetainedWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
|
||||
@@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
|
||||
// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
|
||||
function stripOpenSearchParamFromBrowserUrl() {
|
||||
if (!isWeb || typeof window === "undefined") {
|
||||
return;
|
||||
@@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: {
|
||||
}) {
|
||||
input.navigation.setParams({ open: undefined });
|
||||
if (isWeb) {
|
||||
stripOpenSearchParamFromBrowserUrl();
|
||||
// Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
|
||||
// after that commit so it cannot be restored into the address bar and replayed on reload.
|
||||
window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() {
|
||||
|
||||
function HostWorkspaceRouteContent() {
|
||||
const navigation = useNavigation();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
@@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() {
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
const isAgentOpenIntent = openIntent?.kind === "agent";
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
const openValue = getParamValue(params.open);
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
@@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() {
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
||||
}, [serverId, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
}
|
||||
if (!rootNavigationState?.key) {
|
||||
return;
|
||||
}
|
||||
if (!hasHydratedWorkspaceLayoutStore) {
|
||||
return;
|
||||
}
|
||||
if (isOpenIntentWaitingForWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
clearConsumedOpenIntent({
|
||||
navigation: navigation as unknown as {
|
||||
setParams: (params: { open?: string | undefined }) => void;
|
||||
},
|
||||
});
|
||||
setIntentConsumed(true);
|
||||
return;
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!openValue || !serverId || !workspaceId) return;
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
});
|
||||
prepareWorkspaceTab(
|
||||
{
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
},
|
||||
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
|
||||
);
|
||||
}
|
||||
|
||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||
// skips search params). Strip ?open from the browser URL directly so the
|
||||
// address bar reflects the clean workspace route.
|
||||
clearConsumedOpenIntent({
|
||||
navigation: navigation as unknown as {
|
||||
setParams: (params: { open?: string | undefined }) => void;
|
||||
},
|
||||
});
|
||||
}, [navigation, openValue, serverId, workspaceId]);
|
||||
|
||||
setIntentConsumed(true);
|
||||
}, [
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
if (
|
||||
openValue &&
|
||||
!isOpenIntentWaitingForWorkspace &&
|
||||
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return <WorkspaceDeck />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
function WorkspaceDeck() {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
|
||||
@@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
active={active}
|
||||
recoveryRequested={recoveryRequested}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
function WorkspaceDeckEntry({
|
||||
selection,
|
||||
active,
|
||||
recoveryRequested,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
active: boolean;
|
||||
recoveryRequested: boolean;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
|
||||
@@ -312,7 +257,6 @@ function WorkspaceDeckEntry({
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={active}
|
||||
recoveryRequested={active && recoveryRequested}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() {
|
||||
export async function getLastNotificationResponseAsync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLastNotificationResponse(): void {}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
|
||||
import {
|
||||
FilePanelBarMenu,
|
||||
FilePanelSwitcher,
|
||||
useFilePanelChoiceSnapshot,
|
||||
type FilePanelChoice,
|
||||
} from "@/file-panels";
|
||||
|
||||
/**
|
||||
* The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel
|
||||
* has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest.
|
||||
*/
|
||||
export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
if (!openWith) return null;
|
||||
return (
|
||||
<View style={styles.chrome}>
|
||||
<PaneContentToolbar testID="file-panel-bar">
|
||||
<View style={styles.row}>
|
||||
<View style={styles.spacer} />
|
||||
{modified ? (
|
||||
<View
|
||||
style={styles.modifiedDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
testID="file-panel-modified"
|
||||
/>
|
||||
) : null}
|
||||
<FilePanelSwitcher choice={choice} />
|
||||
<FilePanelBarMenu choice={choice} />
|
||||
</View>
|
||||
</PaneContentToolbar>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
row: {
|
||||
height: "100%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
spacer: { flex: 1 },
|
||||
modifiedDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
}));
|
||||
@@ -15,7 +15,6 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { FilePanelBar } from "./bar";
|
||||
|
||||
/**
|
||||
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
|
||||
@@ -110,7 +109,6 @@ export function FilePane({
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<FilePanelPicker choice={choice} />
|
||||
<FilePanelBar choice={choice} modified={modified} />
|
||||
<MountedFilePanel
|
||||
key={`${panel.id}:${targetKey}`}
|
||||
panel={panel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
|
||||
import { useFileUrl } from "@getpaseo/plugin/client";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -8,23 +8,35 @@ import { FileHtmlPreview } from "./html-preview";
|
||||
import { FilePanelLoading } from "./loading";
|
||||
import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import type { BuiltinFilePanelProps } from "./panels";
|
||||
import { SourcePanel } from "./source/panel";
|
||||
import { TextFilePanel, type TextFilePreviewProps } from "./source/panel";
|
||||
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
|
||||
|
||||
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
|
||||
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
|
||||
const text = useFileText(file);
|
||||
/** Source: the text file panel with no Preview, for text files no other built-in panel claims. */
|
||||
function SourcePanel(props: BuiltinFilePanelProps) {
|
||||
return <TextFilePanel {...props} />;
|
||||
}
|
||||
|
||||
/** Markdown: the text file panel with the rendered document as its Preview. */
|
||||
function MarkdownPanel(props: BuiltinFilePanelProps) {
|
||||
return <TextFilePanel {...props} preview={MarkdownPreview} />;
|
||||
}
|
||||
|
||||
/** HTML: the text file panel with the sandboxed page as its Preview. */
|
||||
function HtmlPanel(props: BuiltinFilePanelProps) {
|
||||
return <TextFilePanel {...props} preview={HtmlPreview} />;
|
||||
}
|
||||
|
||||
/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */
|
||||
function MarkdownPreview({ text, target }: TextFilePreviewProps) {
|
||||
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
|
||||
const [rendered, setRendered] = useState(false);
|
||||
const handleLayout = useCallback(() => setRendered(true), []);
|
||||
if (text.status === "loading") return <FilePanelLoading />;
|
||||
if (text.status === "error") return <FilePanelError message={text.error.message} />;
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator>
|
||||
<View onLayout={handleLayout}>
|
||||
<FileMarkdownPreview
|
||||
source={text.text}
|
||||
source={text}
|
||||
filePath={target.path}
|
||||
workspaceRoot={target.workspaceRoot}
|
||||
client={client}
|
||||
@@ -37,13 +49,11 @@ function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlPanel({ file }: BuiltinFilePanelProps) {
|
||||
const text = useFileText(file);
|
||||
if (text.status === "loading") return <FilePanelLoading />;
|
||||
if (text.status === "error") return <FilePanelError message={text.error.message} />;
|
||||
/** The page owns its scrolling, so there is no ScrollView around it. */
|
||||
function HtmlPreview({ text }: TextFilePreviewProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FileHtmlPreview html={text.text} testID="file-html-preview" />
|
||||
<FileHtmlPreview html={text} testID="file-html-preview" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[]
|
||||
}
|
||||
|
||||
describe("built-in file panels", () => {
|
||||
it("opens each rendered extension with its panel and Source after it", () => {
|
||||
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
|
||||
it("opens each rendered extension with its panel alone, never with Source beside it", () => {
|
||||
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown"]);
|
||||
expect(candidateIds(textFile("plan.html"))).toEqual(["builtin:html"]);
|
||||
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
|
||||
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
|
||||
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
|
||||
@@ -39,16 +40,29 @@ describe("built-in file panels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("puts plugin panels after the rendering built-ins and before Source", () => {
|
||||
it("puts plugin panels after the built-in that claims the file, and before Source", () => {
|
||||
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
|
||||
"builtin:markdown",
|
||||
richMarkdown.id,
|
||||
"builtin:source",
|
||||
]);
|
||||
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
|
||||
expect(candidateIds(textFile("notes.txt"), onlyPlugin)).toEqual([
|
||||
richMarkdown.id,
|
||||
"builtin:source",
|
||||
]);
|
||||
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
|
||||
});
|
||||
|
||||
it("ignores a remembered Source for a file another built-in claims", () => {
|
||||
const resolution = resolveFilePanel({
|
||||
file: textFile("README.md"),
|
||||
candidates: builtins,
|
||||
associations: { "ext:md": "builtin:source" },
|
||||
});
|
||||
expect(resolution.panel?.id).toBe("builtin:markdown");
|
||||
expect(resolution.associatedPanelId).toBeNull();
|
||||
});
|
||||
|
||||
it("has no Source for files that are not text", () => {
|
||||
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,10 @@ const renderingPanels = [
|
||||
},
|
||||
] as const satisfies readonly FilePanelCandidate[];
|
||||
|
||||
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
|
||||
/**
|
||||
* Paseo's editor, the floor for text files: every text file no other built-in claims has it, so a
|
||||
* text file always opens with some panel. A built-in that claims a file owns its source view.
|
||||
*/
|
||||
export const sourceFilePanel = {
|
||||
id: "builtin:source",
|
||||
builtin: true,
|
||||
@@ -34,7 +37,7 @@ export type SourceFilePanel = typeof sourceFilePanel;
|
||||
|
||||
/**
|
||||
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
|
||||
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
|
||||
* panels, then Source. Source is last so a plugin panel is the default over it.
|
||||
*/
|
||||
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
|
||||
pluginPanels: readonly Plugin[],
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
|
||||
import { useFilePanelAssociations } from "../associations";
|
||||
import { sourceFilePanel } from "../catalog";
|
||||
import { useFilePanels, type FilePanel } from "../panels";
|
||||
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
|
||||
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
|
||||
@@ -29,9 +28,7 @@ export function useFilePanelChoice(input: {
|
||||
const { serverId, workspaceId, tabId } = usePaneContext();
|
||||
const candidates = useFilePanels(input.serverId);
|
||||
const { associations, remember } = useFilePanelAssociations();
|
||||
const [choice] = useState(
|
||||
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
|
||||
);
|
||||
const [choice] = useState(() => new FilePanelChoiceModel<FilePanel>({ remember }));
|
||||
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
|
||||
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
|
||||
const resolution = useMemo(
|
||||
|
||||
@@ -8,6 +8,11 @@ const rich: FilePanelCandidate = {
|
||||
builtin: false,
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
};
|
||||
const richTypeScript: FilePanelCandidate = {
|
||||
id: "plugin:ts/outline",
|
||||
builtin: false,
|
||||
files: { extensions: ["ts"] },
|
||||
};
|
||||
const markdown = "builtin:markdown";
|
||||
const source = sourceFilePanel.id;
|
||||
|
||||
@@ -22,7 +27,6 @@ function openTab(
|
||||
if (panelId === null) delete associations[selectorKey];
|
||||
else associations[selectorKey] = panelId;
|
||||
},
|
||||
linePanelId: source,
|
||||
});
|
||||
let targetKey = `/repo:${options.path ?? "notes.md"}`;
|
||||
let presentable = options.presentable ?? true;
|
||||
@@ -70,11 +74,7 @@ describe("FilePanelChoiceModel", () => {
|
||||
selectedPanelId: markdown,
|
||||
remember: false,
|
||||
});
|
||||
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
|
||||
markdown,
|
||||
rich.id,
|
||||
source,
|
||||
]);
|
||||
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([markdown, rich.id]);
|
||||
tab.model.dismissPicker();
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
@@ -84,17 +84,13 @@ describe("FilePanelChoiceModel", () => {
|
||||
expect(openTab(associations).picker()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("never asks between built-in panels, and switches between them", () => {
|
||||
it("opens a file a built-in claims with that built-in alone and nothing to choose", () => {
|
||||
const tab = openTab({}, [...filePanelCatalog([])]);
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
|
||||
markdown,
|
||||
source,
|
||||
]);
|
||||
expect(tab.model.getSnapshot().openWith).toBeNull();
|
||||
tab.model.openWith(source);
|
||||
expect(tab.panel()).toBe(source);
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
});
|
||||
|
||||
it("opens a file only Source claims with Source and nothing to choose", () => {
|
||||
@@ -133,7 +129,7 @@ describe("FilePanelChoiceModel", () => {
|
||||
|
||||
it("does not ask with an association or while the tab is in the background", () => {
|
||||
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
|
||||
expect(openTab({ "ext:md": source }).panel()).toBe(source);
|
||||
expect(openTab({ "ext:md": source }).panel()).toBe(markdown);
|
||||
|
||||
const background = openTab({}, undefined, { presentable: false });
|
||||
expect(background.picker()).toBeNull();
|
||||
@@ -141,21 +137,29 @@ describe("FilePanelChoiceModel", () => {
|
||||
expect(background.picker()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens Source for a line target without asking, and again on each new one", () => {
|
||||
const tab = openTab({}, undefined, { lineNavigation: 1 });
|
||||
expect(tab.panel()).toBe(source);
|
||||
it("opens the built-in panel for a line target without asking, and again on each new one", () => {
|
||||
const tab = openTab({ "ext:md": rich.id }, undefined, { lineNavigation: 1 });
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
expect(tab.picker()).toBeNull();
|
||||
|
||||
tab.model.openWith(rich.id);
|
||||
expect(tab.panel()).toBe(rich.id);
|
||||
expect(tab.navigateToLine(1)).toBe(rich.id);
|
||||
expect(tab.navigateToLine(2)).toBe(source);
|
||||
expect(tab.navigateToLine(2)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("opens Source for the same line target in a retargeted tab", () => {
|
||||
it("opens Source for a line target in a file only Source and a plugin claim", () => {
|
||||
const tab = openTab({ "ext:ts": richTypeScript.id }, [...filePanelCatalog([richTypeScript])], {
|
||||
path: "index.ts",
|
||||
});
|
||||
expect(tab.panel()).toBe(richTypeScript.id);
|
||||
expect(tab.navigateToLine(1)).toBe(source);
|
||||
});
|
||||
|
||||
it("opens the built-in panel for the same line target in a retargeted tab", () => {
|
||||
const tab = openTab({}, undefined, { lineNavigation: 1 });
|
||||
tab.model.openWith(markdown);
|
||||
expect(tab.retarget("other.md")).toBe(source);
|
||||
tab.model.openWith(rich.id);
|
||||
expect(tab.retarget("other.md")).toBe(markdown);
|
||||
});
|
||||
|
||||
it("starts a new open when the tab is retargeted", () => {
|
||||
|
||||
@@ -30,15 +30,13 @@ export interface FilePanelChoiceSnapshot<Candidate> {
|
||||
/** The panel picked for this tab open. Resolution prefers it over the association. */
|
||||
chosenPanelId: string | null;
|
||||
picker: FilePanelPickerState<Candidate> | null;
|
||||
/** Present only when the file has more than one candidate; drives the bar's panel switcher. */
|
||||
/** Present only when the file has more than one candidate; drives "Open with…". */
|
||||
openWith: FilePanelOpenWithState<Candidate> | null;
|
||||
}
|
||||
|
||||
export interface FilePanelChoiceDeps {
|
||||
/** Writes (or clears, with null) the device-wide association for a selector key. */
|
||||
remember(selectorKey: string, panelId: string | null): void;
|
||||
/** The panel a line target opens: Source, the panel that shows lines. */
|
||||
linePanelId: string;
|
||||
}
|
||||
|
||||
/** The tab choice that applies to `targetKey`; a choice never outlives its open. */
|
||||
@@ -61,7 +59,8 @@ const EMPTY: FilePanelChoiceSnapshot<never> = {
|
||||
*
|
||||
* A tab open is one target in one tab: the picker is offered at most once per open, the first
|
||||
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
|
||||
* Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
|
||||
* Built-in panels alone never ask. A line target opens the file's built-in panel, which shows
|
||||
* lines, the way "Open with…" does.
|
||||
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
|
||||
* again" and "Always use" write the association every later open reads.
|
||||
*/
|
||||
@@ -70,6 +69,8 @@ export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
|
||||
private snapshot: FilePanelChoiceSnapshot<Candidate> = EMPTY;
|
||||
private asked = false;
|
||||
private openedLineNavigation: number | null = null;
|
||||
/** A line target arrived before the file resolved; it opens the built-in once it does. */
|
||||
private lineTargetPending = false;
|
||||
|
||||
constructor(private readonly deps: FilePanelChoiceDeps) {}
|
||||
|
||||
@@ -85,11 +86,17 @@ export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
|
||||
if (source.targetKey !== current.targetKey) {
|
||||
this.asked = false;
|
||||
this.openedLineNavigation = null;
|
||||
this.lineTargetPending = false;
|
||||
current = { ...EMPTY, targetKey: source.targetKey };
|
||||
}
|
||||
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
|
||||
this.openedLineNavigation = source.lineNavigation;
|
||||
current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
|
||||
this.lineTargetPending = true;
|
||||
}
|
||||
if (this.lineTargetPending && source.resolution) {
|
||||
this.lineTargetPending = false;
|
||||
const builtin = source.resolution.candidates.find((candidate) => candidate.builtin);
|
||||
if (builtin) current = { ...current, chosenPanelId: builtin.id, picker: null };
|
||||
}
|
||||
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
|
||||
let picker = openWith ? current.picker : null;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { Ellipsis } from "lucide-react-native";
|
||||
import {
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/menu";
|
||||
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -22,7 +17,6 @@ import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./
|
||||
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
|
||||
const MENU_ICON_SIZE = 16;
|
||||
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedPanelIcon = withUnistyles(PluginIconView);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -43,41 +37,6 @@ export function useFilePanelOpenWithMenu(
|
||||
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
|
||||
* the file has more than one panel.
|
||||
*/
|
||||
export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
if (!openWith) return null;
|
||||
const label = t("panels.file.filePanels.actions");
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton kind="menu" label={label} compact={compact} testID="file-panel-bar-menu">
|
||||
<ThemedEllipsis
|
||||
size={paneContentToolbarIconSize(compact)}
|
||||
uniProps={extraMutedIconColorMapping}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={240}
|
||||
sheetTitle={label}
|
||||
testID="file-panel-bar-menu-surface"
|
||||
>
|
||||
<AlwaysUseItem
|
||||
choice={choice}
|
||||
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
|
||||
selected={openWith.remembered}
|
||||
testID="file-panel-bar-menu-always-use"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function useOpenWithMenu(
|
||||
choice: FilePanelChoice | null,
|
||||
testIDBase: string,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function filePanelIcon(panel: FilePanel): PluginIcon {
|
||||
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
|
||||
}
|
||||
|
||||
/** "Markdown", "Rich Markdown": the panel switcher's label. */
|
||||
/** "Markdown", "Rich Markdown": the panel's title. */
|
||||
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
|
||||
return panel.builtin
|
||||
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FilePanelOpenWithState } from "./model";
|
||||
import { filePanelSwitcherState } from "./switcher-layout";
|
||||
|
||||
function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
|
||||
const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
|
||||
return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
|
||||
}
|
||||
|
||||
describe("filePanelSwitcherState", () => {
|
||||
it("shows nothing when the file has one panel or none", () => {
|
||||
expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("sets up to three panels side by side and more behind a menu", () => {
|
||||
const wide = { compact: false };
|
||||
expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
|
||||
expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
|
||||
expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
|
||||
layout: "menu",
|
||||
activePanelId: "panel-1",
|
||||
candidates: openWith(4).candidates,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets two panels side by side on a compact screen and more behind a menu", () => {
|
||||
const compact = { compact: true };
|
||||
expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
|
||||
expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { FilePanelOpenWithState } from "./model";
|
||||
|
||||
/** Up to this many panels sit side by side in the bar; more go behind a menu. */
|
||||
const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
|
||||
|
||||
export interface FilePanelSwitcherState<Candidate> {
|
||||
layout: "segmented" | "menu";
|
||||
candidates: readonly Candidate[];
|
||||
activePanelId: string;
|
||||
}
|
||||
|
||||
/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */
|
||||
export function filePanelSwitcherState<Candidate>(
|
||||
openWith: FilePanelOpenWithState<Candidate> | null,
|
||||
{ compact }: { compact: boolean },
|
||||
): FilePanelSwitcherState<Candidate> | null {
|
||||
if (!openWith) return null;
|
||||
const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide;
|
||||
return {
|
||||
layout: openWith.candidates.length > limit ? "menu" : "segmented",
|
||||
candidates: openWith.candidates,
|
||||
activePanelId: openWith.activePanelId,
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { MenuItem } from "@/components/ui/menu";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import {
|
||||
ToolbarLabelTriggerIcon,
|
||||
isToolbarLabelTriggerHighlighted,
|
||||
toolbarLabelTriggerStyle,
|
||||
toolbarLabelTriggerTextStyle,
|
||||
} from "@/components/ui/toolbar-label-trigger";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { FilePanel } from "../panels";
|
||||
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
|
||||
import { filePanelIcon, filePanelTitle } from "./presentation";
|
||||
import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedPanelIcon = withUnistyles(PluginIconView);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
|
||||
export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const switcher = filePanelSwitcherState(openWith, { compact });
|
||||
const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
|
||||
if (!switcher) return null;
|
||||
return switcher.layout === "segmented" ? (
|
||||
<SegmentedSwitcher switcher={switcher} onSelect={select} />
|
||||
) : (
|
||||
<MenuSwitcher switcher={switcher} onSelect={select} />
|
||||
);
|
||||
}
|
||||
|
||||
interface SwitcherProps {
|
||||
switcher: FilePanelSwitcherState<FilePanel>;
|
||||
onSelect(panelId: string): void;
|
||||
}
|
||||
|
||||
function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
switcher.candidates.map((panel) => ({
|
||||
value: panel.id,
|
||||
label: filePanelTitle(t, panel),
|
||||
testID: `file-panel-switcher-${panel.id}`,
|
||||
})),
|
||||
[switcher.candidates, t],
|
||||
);
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={switcher.activePanelId}
|
||||
onValueChange={onSelect}
|
||||
options={options}
|
||||
testID="file-panel-switcher"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSwitcher({ switcher, onSelect }: SwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId);
|
||||
const activeTitle = active ? filePanelTitle(t, active) : "";
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={toolbarLabelTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("panels.file.filePanels.openWith")}
|
||||
testID="file-panel-switcher"
|
||||
>
|
||||
{(state) => (
|
||||
<>
|
||||
<Text
|
||||
style={toolbarLabelTriggerTextStyle(isToolbarLabelTriggerHighlighted(state))}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{activeTitle}
|
||||
</Text>
|
||||
<ToolbarLabelTriggerIcon>
|
||||
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
|
||||
</ToolbarLabelTriggerIcon>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} testID="file-panel-switcher-menu">
|
||||
{switcher.candidates.map((panel) => (
|
||||
<SwitcherMenuItem
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
selected={panel.id === switcher.activePanelId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitcherMenuItem({
|
||||
panel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
panel: FilePanel;
|
||||
selected: boolean;
|
||||
onSelect(panelId: string): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
|
||||
const leading = useMemo(
|
||||
() => <ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />,
|
||||
[panel],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
testID={`file-panel-switcher-${panel.id}`}
|
||||
>
|
||||
{filePanelTitle(t, panel)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,7 @@ export {
|
||||
type FilePanelChoice,
|
||||
} from "./choice/hook";
|
||||
export { FilePanelPicker } from "./choice/picker";
|
||||
export { FilePanelSwitcher } from "./choice/switcher";
|
||||
export {
|
||||
FilePanelBarMenu,
|
||||
useFilePanelOpenWithMenu,
|
||||
type FilePanelOpenWithMenu,
|
||||
} from "./choice/open-with";
|
||||
export { useFilePanelOpenWithMenu, type FilePanelOpenWithMenu } from "./choice/open-with";
|
||||
export { DocumentFrame } from "./document-frame";
|
||||
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
|
||||
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
|
||||
|
||||
@@ -70,6 +70,17 @@ describe("resolveFilePanel", () => {
|
||||
});
|
||||
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
|
||||
});
|
||||
it("offers a built-in panel claiming every file only when no other built-in claims the file", () => {
|
||||
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
|
||||
expect(resolve({ candidates: [markdown, alternative, every] }).candidates).toEqual([
|
||||
markdown,
|
||||
alternative,
|
||||
]);
|
||||
expect(resolve({ candidates: [alternative, every] }).candidates).toEqual([alternative, every]);
|
||||
expect(
|
||||
resolve({ file: { ...file, kind: "binary" }, candidates: [markdown, every] }).candidates,
|
||||
).toEqual([]);
|
||||
});
|
||||
it("passes over a built-in panel for another kind of file, even with an association", () => {
|
||||
expect(
|
||||
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
|
||||
|
||||
@@ -99,6 +99,14 @@ export function claimsFilePanelSelectorKey(
|
||||
: matchesName("", value, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* A built-in panel claiming every file of its kind (Source) is the floor: a candidate only for
|
||||
* files no other built-in panel claims, never an alternative beside one.
|
||||
*/
|
||||
function isBuiltinFloor(candidate: FilePanelCandidate): boolean {
|
||||
return candidate.builtin && candidate.files === "all";
|
||||
}
|
||||
|
||||
/** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */
|
||||
export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean {
|
||||
return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin);
|
||||
@@ -112,11 +120,17 @@ export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
|
||||
choice?: string | null;
|
||||
}): FilePanelResolution<Candidate> {
|
||||
const { file, associations } = input;
|
||||
const candidates = input.candidates.filter(
|
||||
const matching = input.candidates.filter(
|
||||
(candidate) =>
|
||||
(candidate.fileKind === undefined || candidate.fileKind === file.kind) &&
|
||||
matchesFiles(file, candidate.files),
|
||||
);
|
||||
const claimedByBuiltin = matching.some(
|
||||
(candidate) => candidate.builtin && candidate.files !== "all",
|
||||
);
|
||||
const candidates = claimedByBuiltin
|
||||
? matching.filter((candidate) => !isBuiltinFloor(candidate))
|
||||
: matching;
|
||||
const filename = filePanelBasename(file.path);
|
||||
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
|
||||
const selectorKey = matchedFilename
|
||||
|
||||
@@ -39,12 +39,12 @@ describe("buildFilePanelSettingsRows", () => {
|
||||
expect(rows(catalog())).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists a key a plugin panel claims, built-in first and Source last", () => {
|
||||
it("lists a key a plugin panel claims with the built-in first and no Source", () => {
|
||||
const plugin = richMarkdown("host-a");
|
||||
expect(rows(catalog(plugin))).toEqual([
|
||||
{
|
||||
selectorKey: "ext:md",
|
||||
candidates: [builtinMarkdown, plugin, source],
|
||||
candidates: [builtinMarkdown, plugin],
|
||||
associatedPanelId: null,
|
||||
},
|
||||
]);
|
||||
@@ -77,19 +77,25 @@ describe("buildFilePanelSettingsRows", () => {
|
||||
it("offers a panel installed on several hosts once, as the first host has it", () => {
|
||||
const first = richMarkdown("host-a");
|
||||
const [row] = rows(catalog(first, richMarkdown("host-b")));
|
||||
expect(row?.candidates).toEqual([builtinMarkdown, first, source]);
|
||||
expect(row?.candidates).toEqual([builtinMarkdown, first]);
|
||||
});
|
||||
|
||||
it("keeps a remembered key that only built-in panels claim", () => {
|
||||
expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([
|
||||
{
|
||||
selectorKey: "ext:markdown",
|
||||
candidates: [builtinMarkdown, source],
|
||||
candidates: [builtinMarkdown],
|
||||
associatedPanelId: source.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers only Source on a text key no other panel claims", () => {
|
||||
expect(rows(catalog(), { "ext:ts": source.id })).toEqual([
|
||||
{ selectorKey: "ext:ts", candidates: [source], associatedPanelId: source.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a remembered key whose panel is on no connected host", () => {
|
||||
expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([
|
||||
{ selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" },
|
||||
@@ -104,7 +110,7 @@ describe("buildFilePanelSettingsRows", () => {
|
||||
};
|
||||
const keys = rows(catalog(named)).map((row) => row.selectorKey);
|
||||
expect(keys).toEqual(["name:README.md"]);
|
||||
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]);
|
||||
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named]);
|
||||
});
|
||||
|
||||
it("orders extensions before filenames, each alphabetically", () => {
|
||||
|
||||
@@ -40,10 +40,9 @@ export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>
|
||||
}
|
||||
|
||||
/**
|
||||
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file of
|
||||
* a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a
|
||||
* file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file
|
||||
* when it opens is ignored there.
|
||||
* The panels offered under a key, by the resolver's matching rule. The built-in floor (Source) is
|
||||
* offered only when no built-in panel claims the key. A remembered panel that does not fit the
|
||||
* file when it opens is ignored there.
|
||||
*/
|
||||
function offeredCandidates<Candidate extends FilePanelCandidate>(
|
||||
candidates: readonly Candidate[],
|
||||
@@ -55,7 +54,7 @@ function offeredCandidates<Candidate extends FilePanelCandidate>(
|
||||
);
|
||||
return candidates.filter((candidate) =>
|
||||
candidate.files === "all"
|
||||
? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind)
|
||||
? !claiming.some((claimant) => claimant.builtin)
|
||||
: claiming.includes(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useFileText } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,12 +19,12 @@ import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FilePanelLoading } from "../loading";
|
||||
import type { BuiltinFilePanelProps } from "../panels";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { selectSourcePresentation } from "./readonly/presentation";
|
||||
import { FileSourceView } from "./readonly/view";
|
||||
import { SourceStatusRow } from "./status";
|
||||
import { TextFileToolbar, type TextFileView } from "./toolbar";
|
||||
|
||||
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
|
||||
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
|
||||
@@ -40,19 +48,49 @@ const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
|
||||
/** The daemon refuses to write larger files. */
|
||||
const MAX_EDITABLE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file
|
||||
* on native read-only, and refuses files above the display budget before reading them.
|
||||
*/
|
||||
export function SourcePanel(props: BuiltinFilePanelProps) {
|
||||
const size = props.file.size;
|
||||
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
|
||||
if (presentation === "unsupported") return <TooLargeSource size={size} />;
|
||||
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableSource {...props} />;
|
||||
return <ReadonlySource {...props} presentation={presentation} />;
|
||||
/** A rendered view of the file's text: the panel's Preview, shown instead of its source. */
|
||||
export interface TextFilePreviewProps {
|
||||
text: string;
|
||||
target: BuiltinFilePanelProps["target"];
|
||||
}
|
||||
|
||||
function TooLargeSource({ size }: { size: number }) {
|
||||
type TextFilePanelProps = BuiltinFilePanelProps & {
|
||||
/** The panel's Preview; with one, the toolbar switches between Preview and Source. */
|
||||
preview?: ComponentType<TextFilePreviewProps>;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the Markdown, HTML and Source panels share: a toolbar over the file's Source view and, when
|
||||
* the panel passes one, its Preview. Both views show the one read of the file and, while editing, the one editor buffer, so switching
|
||||
* between them neither re-reads nor loses edits. Edits files up to 1 MiB on web; shows larger files
|
||||
* and every file on native read-only, and refuses files above the display budget before reading.
|
||||
*/
|
||||
export function TextFilePanel(props: TextFilePanelProps) {
|
||||
const size = props.file.size;
|
||||
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
|
||||
if (presentation === "unsupported") return <TooLargeTextFile size={size} />;
|
||||
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableTextFile {...props} />;
|
||||
return <ReadonlyTextFile {...props} presentation={presentation} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view a panel with a preview shows. It opens on Preview, and on Source for a line target;
|
||||
* each later line target switches to Source again.
|
||||
*/
|
||||
function useTextFileView(
|
||||
location: PluginFileLocation,
|
||||
): [TextFileView, (view: TextFileView) => void] {
|
||||
const lineTarget = location.lineStart ? location.revision : null;
|
||||
const [view, setView] = useState<TextFileView>(lineTarget === null ? "preview" : "source");
|
||||
const [shownLineTarget, setShownLineTarget] = useState(lineTarget);
|
||||
if (lineTarget !== null && lineTarget !== shownLineTarget) {
|
||||
setShownLineTarget(lineTarget);
|
||||
setView("source");
|
||||
}
|
||||
return [view, setView];
|
||||
}
|
||||
|
||||
function TooLargeTextFile({ size }: { size: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
@@ -62,14 +100,16 @@ function TooLargeSource({ size }: { size: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ReadonlySource({
|
||||
function ReadonlyTextFile({
|
||||
file,
|
||||
location,
|
||||
target,
|
||||
preview: Preview,
|
||||
presentation,
|
||||
}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) {
|
||||
}: TextFilePanelProps & { presentation: "highlighted" | "plain" }) {
|
||||
const text = useFileText(file);
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useTextFileView(location);
|
||||
const sourceLocation = useMemo(
|
||||
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
|
||||
[location.lineEnd, location.lineStart, target.path],
|
||||
@@ -82,25 +122,35 @@ function ReadonlySource({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const showPreview = Preview !== undefined && view === "preview";
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ThemedFileSourceView
|
||||
content={text.text}
|
||||
filename={target.path}
|
||||
location={sourceLocation}
|
||||
navigationRevision={location.revision}
|
||||
presentation={presentation}
|
||||
<TextFileToolbar
|
||||
size={file.size}
|
||||
lineCount={text.text.split("\n").length}
|
||||
view={Preview ? view : undefined}
|
||||
onViewChange={Preview ? setView : undefined}
|
||||
/>
|
||||
<SourceStatusRow size={file.size} lineCount={text.text.split("\n").length} />
|
||||
{showPreview ? (
|
||||
<Preview text={text.text} target={target} />
|
||||
) : (
|
||||
<ThemedFileSourceView
|
||||
content={text.text}
|
||||
filename={target.path}
|
||||
location={sourceLocation}
|
||||
navigationRevision={location.revision}
|
||||
presentation={presentation}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) {
|
||||
function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) {
|
||||
const { settings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const [model] = useState(() => new FileEditorModel({ file }));
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [view, setView] = useTextFileView(location);
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
@@ -130,7 +180,6 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
|
||||
}, [model, snapshot.modified, t]);
|
||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||
const handleRetry = useCallback(() => void model.reload(), [model]);
|
||||
const handleReady = useCallback(() => setEditorReady(true), []);
|
||||
const editorLocation = useMemo(
|
||||
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
|
||||
[location.lineEnd, location.lineStart, target.path],
|
||||
@@ -159,11 +208,23 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
|
||||
onOverwrite: handleOverwrite,
|
||||
};
|
||||
}
|
||||
const showPreview = Preview !== undefined && view === "preview";
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
||||
<View style={styles.editor}>
|
||||
<ThemedFileEditorView
|
||||
<TextFileToolbar
|
||||
size={file.size}
|
||||
lineCount={snapshot.content.split("\n").length}
|
||||
view={Preview ? view : undefined}
|
||||
onViewChange={Preview ? setView : undefined}
|
||||
editorStatus={snapshot.status}
|
||||
cursor={showPreview ? undefined : cursor}
|
||||
vimMode={showPreview ? null : vimMode}
|
||||
conflict={conflict}
|
||||
/>
|
||||
{showPreview ? (
|
||||
<Preview text={snapshot.content} target={target} />
|
||||
) : (
|
||||
<SourceEditor
|
||||
model={model}
|
||||
filename={target.path}
|
||||
location={editorLocation}
|
||||
@@ -171,17 +232,20 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
|
||||
vimEnabled={settings.vimKeybindings}
|
||||
onCursorChange={setCursor}
|
||||
onVimModeChange={setVimMode}
|
||||
onReady={handleReady}
|
||||
/>
|
||||
{editorReady ? null : <FilePanelLoading />}
|
||||
</View>
|
||||
<SourceStatusRow
|
||||
size={file.size}
|
||||
lineCount={snapshot.content.split("\n").length}
|
||||
editorStatus={snapshot.status}
|
||||
vimMode={vimMode}
|
||||
cursor={cursor}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor over the panel's buffer. Each mount shows Loading until the editor shows the text. */
|
||||
function SourceEditor(props: Omit<ComponentProps<typeof ThemedFileEditorView>, "onReady">) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const handleReady = useCallback(() => setReady(true), []);
|
||||
return (
|
||||
<View style={styles.editor}>
|
||||
<ThemedFileEditorView {...props} onReady={handleReady} />
|
||||
{ready ? null : <FilePanelLoading />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */
|
||||
export function SourceStatusRow({
|
||||
size,
|
||||
lineCount,
|
||||
editorStatus,
|
||||
vimMode,
|
||||
cursor,
|
||||
}: {
|
||||
size: number;
|
||||
lineCount: number;
|
||||
editorStatus?: FileEditorStatus;
|
||||
vimMode?: string | null;
|
||||
cursor?: { line: number; column: number };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.row} testID="file-source-status">
|
||||
<View style={styles.metadata}>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
numberOfLines={1}
|
||||
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||
>
|
||||
{formatFileSize(size)}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
numberOfLines={1}
|
||||
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||
>
|
||||
{t("panels.file.editor.lines", { count: lineCount })}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityLabel={
|
||||
editorStatus ? t("panels.file.editor.editorStatus", { status: editorStatus }) : undefined
|
||||
}
|
||||
>
|
||||
{editorStatus === "dirty" ? (
|
||||
<View
|
||||
style={styles.dirtyDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
/>
|
||||
) : null}
|
||||
{editorStatus === "saving" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{editorStatus === "error" ? (
|
||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||
) : null}
|
||||
{vimMode ? (
|
||||
<Text
|
||||
style={styles.vim}
|
||||
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||
>
|
||||
{vimMode}
|
||||
</Text>
|
||||
) : null}
|
||||
{cursor ? (
|
||||
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
|
||||
Ln {cursor.line}, Col {cursor.column}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexShrink: 0,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
metadata: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
|
||||
dirtyDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
vim: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export type TextFileView = "preview" | "source";
|
||||
|
||||
/**
|
||||
* The text file panel's toolbar: the file's size and lines, the save state, the vim mode and the
|
||||
* cursor, the Preview|Source control when the panel has a preview, and a conflict under it.
|
||||
*/
|
||||
export function TextFileToolbar({
|
||||
size,
|
||||
lineCount,
|
||||
view,
|
||||
onViewChange,
|
||||
editorStatus,
|
||||
cursor,
|
||||
vimMode,
|
||||
conflict,
|
||||
}: {
|
||||
size: number;
|
||||
lineCount: number;
|
||||
view?: TextFileView;
|
||||
onViewChange?(view: TextFileView): void;
|
||||
editorStatus?: FileEditorStatus;
|
||||
cursor?: { line: number; column: number };
|
||||
vimMode?: string | null;
|
||||
conflict?: FileConflictAlertState | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const views = [
|
||||
{
|
||||
value: "preview" as const,
|
||||
label: t("panels.file.editor.preview"),
|
||||
testID: "file-mode-preview",
|
||||
},
|
||||
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
||||
];
|
||||
return (
|
||||
<View style={styles.chrome}>
|
||||
<PaneContentToolbar testID="file-panel-bar">
|
||||
<View style={styles.row}>
|
||||
<View style={styles.metadata}>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
numberOfLines={1}
|
||||
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||
>
|
||||
{formatFileSize(size)}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
numberOfLines={1}
|
||||
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||
>
|
||||
{t("panels.file.editor.lines", { count: lineCount })}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityLabel={
|
||||
editorStatus
|
||||
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{editorStatus === "dirty" ? (
|
||||
<View
|
||||
style={styles.dirtyDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
/>
|
||||
) : null}
|
||||
{editorStatus === "saving" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{editorStatus === "error" ? (
|
||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||
) : null}
|
||||
{vimMode ? (
|
||||
<Text
|
||||
style={styles.vim}
|
||||
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||
>
|
||||
{vimMode}
|
||||
</Text>
|
||||
) : null}
|
||||
{cursor ? (
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
||||
>
|
||||
Ln {cursor.line}, Col {cursor.column}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{view && onViewChange ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={view}
|
||||
onValueChange={onViewChange}
|
||||
testID="file-preview-mode"
|
||||
options={views}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</PaneContentToolbar>
|
||||
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
row: {
|
||||
height: "100%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
metadata: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
|
||||
dirtyDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
vim: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -1928,7 +1928,6 @@ export const ar: TranslationResources = {
|
||||
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
|
||||
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "إجراءات الملف",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1947,6 +1946,8 @@ export const ar: TranslationResources = {
|
||||
notFound: "الملف غير موجود",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
editor: {
|
||||
preview: "معاينة",
|
||||
source: "المصدر",
|
||||
fileSize: "حجم الملف {{size}}",
|
||||
lines: "{{count}} سطر",
|
||||
editorStatus: "حالة المحرر {{status}}",
|
||||
@@ -2252,7 +2253,7 @@ export const ar: TranslationResources = {
|
||||
automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا",
|
||||
preferredHint: "اختر النموذج الذي يستخدمه Paseo",
|
||||
model: "النموذج",
|
||||
fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا",
|
||||
modelHint: "يُستخدم هذا النموذج فقط. إذا فشل، لا يجرّب Paseo نموذجًا آخر",
|
||||
docs: "الوثائق",
|
||||
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
|
||||
},
|
||||
|
||||
@@ -1953,7 +1953,6 @@ export const en = {
|
||||
alwaysUseExtension: "Always use for .{{extension}}",
|
||||
alwaysUseFilename: "Always use for {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "File actions",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1972,6 +1971,8 @@ export const en = {
|
||||
notFound: "File not found",
|
||||
failedToLoadPreview: "Failed to load file preview",
|
||||
editor: {
|
||||
preview: "Preview",
|
||||
source: "Source",
|
||||
fileSize: "File size {{size}}",
|
||||
lines: "{{count}} lines",
|
||||
editorStatus: "Editor status {{status}}",
|
||||
@@ -2341,7 +2342,7 @@ export const en = {
|
||||
automaticHint: "Paseo picks a fast available model",
|
||||
preferredHint: "Choose the model Paseo uses",
|
||||
model: "Model",
|
||||
fallbackHint: "If it is unavailable, Paseo falls back to another available model",
|
||||
modelHint: "Only this model is used. If it fails, Paseo does not try another model",
|
||||
docs: "Docs",
|
||||
saveError: "Unable to update metadata generation",
|
||||
},
|
||||
|
||||
@@ -1976,7 +1976,6 @@ export const es: TranslationResources = {
|
||||
alwaysUseExtension: "Usar siempre para .{{extension}}",
|
||||
alwaysUseFilename: "Usar siempre para {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Acciones del archivo",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1995,6 +1994,8 @@ export const es: TranslationResources = {
|
||||
notFound: "No se encontró el archivo",
|
||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||
editor: {
|
||||
preview: "Vista previa",
|
||||
source: "Código fuente",
|
||||
fileSize: "Tamaño {{size}}",
|
||||
lines: "{{count}} líneas",
|
||||
editorStatus: "Estado del editor: {{status}}",
|
||||
@@ -2264,7 +2265,7 @@ export const es: TranslationResources = {
|
||||
automaticHint: "Paseo elige un modelo rápido disponible",
|
||||
preferredHint: "Elige el modelo que usa Paseo",
|
||||
model: "Modelo",
|
||||
fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible",
|
||||
modelHint: "Solo se usa este modelo. Si falla, Paseo no prueba otro modelo",
|
||||
docs: "Documentación",
|
||||
saveError: "No se pudo actualizar la generación de metadatos",
|
||||
},
|
||||
|
||||
@@ -1974,7 +1974,6 @@ export const fr: TranslationResources = {
|
||||
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
|
||||
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Actions du fichier",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1993,6 +1992,8 @@ export const fr: TranslationResources = {
|
||||
notFound: "Fichier introuvable",
|
||||
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
|
||||
editor: {
|
||||
preview: "Aperçu",
|
||||
source: "Source",
|
||||
fileSize: "Taille du fichier : {{size}}",
|
||||
lines: "{{count}} lignes",
|
||||
editorStatus: "État de l’éditeur : {{status}}",
|
||||
@@ -2264,7 +2265,7 @@ export const fr: TranslationResources = {
|
||||
automaticHint: "Paseo choisit un modèle rapide disponible",
|
||||
preferredHint: "Choisissez le modèle utilisé par Paseo",
|
||||
model: "Modèle",
|
||||
fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible",
|
||||
modelHint: "Seul ce modèle est utilisé. S’il échoue, Paseo n’essaie pas d’autre modèle",
|
||||
docs: "Documentation",
|
||||
saveError: "Impossible de mettre à jour la génération de métadonnées",
|
||||
},
|
||||
|
||||
@@ -1947,7 +1947,6 @@ export const ja: TranslationResources = {
|
||||
alwaysUseExtension: ".{{extension}} に常に使用",
|
||||
alwaysUseFilename: "{{filename}} に常に使用",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "ファイルの操作",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1966,6 +1965,8 @@ export const ja: TranslationResources = {
|
||||
notFound: "ファイルが見つかりません",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
editor: {
|
||||
preview: "プレビュー",
|
||||
source: "ソース",
|
||||
fileSize: "ファイルサイズ {{size}}",
|
||||
lines: "{{count}} 行",
|
||||
editorStatus: "エディターの状態 {{status}}",
|
||||
@@ -2237,7 +2238,7 @@ export const ja: TranslationResources = {
|
||||
automaticHint: "Paseo が利用可能な高速モデルを選択します",
|
||||
preferredHint: "Paseo が使用するモデルを選択します",
|
||||
model: "モデル",
|
||||
fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します",
|
||||
modelHint: "このモデルのみを使用します。失敗しても、Paseo は別のモデルを試しません",
|
||||
docs: "ドキュメント",
|
||||
saveError: "メタデータ生成を更新できません",
|
||||
},
|
||||
|
||||
@@ -1938,7 +1938,6 @@ export const ko: TranslationResources = {
|
||||
alwaysUseExtension: ".{{extension}}에 항상 사용",
|
||||
alwaysUseFilename: "{{filename}}에 항상 사용",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "파일 작업",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1957,6 +1956,8 @@ export const ko: TranslationResources = {
|
||||
notFound: "파일을 찾을 수 없습니다",
|
||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||
editor: {
|
||||
preview: "미리보기",
|
||||
source: "소스",
|
||||
fileSize: "파일 크기 {{size}}",
|
||||
lines: "{{count}} 라인",
|
||||
editorStatus: "편집기 상태 {{status}}",
|
||||
@@ -2226,7 +2227,7 @@ export const ko: TranslationResources = {
|
||||
automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다",
|
||||
preferredHint: "Paseo에서 사용할 모델을 선택하세요",
|
||||
model: "모델",
|
||||
fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다",
|
||||
modelHint: "이 모델만 사용합니다. 실패해도 Paseo는 다른 모델을 시도하지 않습니다",
|
||||
docs: "문서",
|
||||
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
|
||||
},
|
||||
|
||||
@@ -1961,7 +1961,6 @@ export const ptBR: TranslationResources = {
|
||||
alwaysUseExtension: "Sempre usar para .{{extension}}",
|
||||
alwaysUseFilename: "Sempre usar para {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Ações do arquivo",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1980,6 +1979,8 @@ export const ptBR: TranslationResources = {
|
||||
notFound: "Arquivo não encontrado",
|
||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||
editor: {
|
||||
preview: "Prévia",
|
||||
source: "Fonte",
|
||||
fileSize: "Tamanho {{size}}",
|
||||
lines: "{{count}} linhas",
|
||||
editorStatus: "Status do editor: {{status}}",
|
||||
@@ -2249,7 +2250,7 @@ export const ptBR: TranslationResources = {
|
||||
automaticHint: "O Paseo escolhe um modelo rápido disponível",
|
||||
preferredHint: "Escolha o modelo que o Paseo usa",
|
||||
model: "Modelo",
|
||||
fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível",
|
||||
modelHint: "Somente este modelo é usado. Se ele falhar, o Paseo não tenta outro modelo",
|
||||
docs: "Documentação",
|
||||
saveError: "Não foi possível atualizar a geração de metadados",
|
||||
},
|
||||
|
||||
@@ -1960,7 +1960,6 @@ export const ru: TranslationResources = {
|
||||
alwaysUseExtension: "Всегда использовать для .{{extension}}",
|
||||
alwaysUseFilename: "Всегда использовать для {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Действия с файлом",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1979,6 +1978,8 @@ export const ru: TranslationResources = {
|
||||
notFound: "Файл не найден",
|
||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||
editor: {
|
||||
preview: "Предпросмотр",
|
||||
source: "Исходный текст",
|
||||
fileSize: "Размер файла {{size}}",
|
||||
lines: "Строк: {{count}}",
|
||||
editorStatus: "Состояние редактора: {{status}}",
|
||||
@@ -2249,7 +2250,8 @@ export const ru: TranslationResources = {
|
||||
automaticHint: "Paseo выбирает быструю доступную модель",
|
||||
preferredHint: "Выберите модель, которую использует Paseo",
|
||||
model: "Модель",
|
||||
fallbackHint: "Если она недоступна, Paseo использует другую доступную модель",
|
||||
modelHint:
|
||||
"Используется только эта модель. Если она не сработает, Paseo не будет пробовать другую модель",
|
||||
docs: "Документация",
|
||||
saveError: "Не удалось обновить настройки генерации метаданных",
|
||||
},
|
||||
|
||||
@@ -1906,7 +1906,6 @@ export const zhCN: TranslationResources = {
|
||||
alwaysUseExtension: "始终用于 .{{extension}}",
|
||||
alwaysUseFilename: "始终用于 {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "文件操作",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1925,6 +1924,8 @@ export const zhCN: TranslationResources = {
|
||||
notFound: "找不到文件",
|
||||
failedToLoadPreview: "加载文件预览失败",
|
||||
editor: {
|
||||
preview: "预览",
|
||||
source: "源代码",
|
||||
fileSize: "文件大小 {{size}}",
|
||||
lines: "{{count}} 行",
|
||||
editorStatus: "编辑器状态 {{status}}",
|
||||
@@ -2193,7 +2194,7 @@ export const zhCN: TranslationResources = {
|
||||
automaticHint: "Paseo 会选择一个可用的快速模型",
|
||||
preferredHint: "选择 Paseo 使用的模型",
|
||||
model: "模型",
|
||||
fallbackHint: "如果不可用,Paseo 会改用其他可用模型",
|
||||
modelHint: "只使用此模型。如果失败,Paseo 不会尝试其他模型",
|
||||
docs: "文档",
|
||||
saveError: "无法更新元数据生成设置",
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ const VALID_ROUTE = {
|
||||
serverId: "server-1",
|
||||
agentId: "agent-1",
|
||||
cachedWorkspaceId: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
hasHydratedLayout: true,
|
||||
} as const;
|
||||
|
||||
describe("resolveAgentRoute", () => {
|
||||
@@ -32,6 +34,20 @@ describe("resolveAgentRoute", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ hasHydratedWorkspaces: false, hasHydratedLayout: true },
|
||||
{ hasHydratedWorkspaces: true, hasHydratedLayout: false },
|
||||
])("waits for workspace and layout hydration: %j", (hydration) => {
|
||||
expect(
|
||||
resolveAgentRoute({
|
||||
...VALID_ROUTE,
|
||||
...hydration,
|
||||
connectionStatus: "online",
|
||||
lookup: { kind: "found", workspaceId: "workspace-2" },
|
||||
}),
|
||||
).toEqual({ kind: "fetchingAgent" });
|
||||
});
|
||||
|
||||
it("fetches the agent after its target host connects", () => {
|
||||
expect(
|
||||
resolveAgentRoute({
|
||||
|
||||
@@ -24,13 +24,18 @@ export function resolveAgentRoute(input: {
|
||||
cachedWorkspaceId: string | null | undefined;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
lookup: AgentRouteLookup;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
hasHydratedLayout: boolean;
|
||||
}): AgentRouteResolution {
|
||||
if (!input.serverId || !input.agentId) {
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
|
||||
const hydrationPending = !input.hasHydratedWorkspaces || !input.hasHydratedLayout;
|
||||
if (hydrationPending && input.connectionStatus === "online") return { kind: "fetchingAgent" };
|
||||
|
||||
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
|
||||
if (cachedWorkspaceId) {
|
||||
if (cachedWorkspaceId && !hydrationPending) {
|
||||
return { kind: "resolved", workspaceId: cachedWorkspaceId };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createNotificationOpenRouter,
|
||||
parseNotificationOpenTarget,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
} from "./model";
|
||||
export type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNotificationOpenRouter, parseNotificationOpenTarget } from "./model";
|
||||
import type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
|
||||
class FakeSource implements NotificationOpenSource {
|
||||
listeners = new Set<(tap: NotificationTap) => void>();
|
||||
acknowledged: string[] = [];
|
||||
launchReads = 0;
|
||||
constructor(readonly launch: NotificationTap | null) {}
|
||||
subscribe(onTap: (tap: NotificationTap) => void) {
|
||||
this.listeners.add(onTap);
|
||||
return () => {
|
||||
this.listeners.delete(onTap);
|
||||
};
|
||||
}
|
||||
async takeLaunchTap() {
|
||||
this.launchReads++;
|
||||
return this.launch;
|
||||
}
|
||||
acknowledge(tap: NotificationTap) {
|
||||
this.acknowledged.push(tap.id);
|
||||
}
|
||||
tap(tap: NotificationTap) {
|
||||
for (const listener of this.listeners) listener(tap);
|
||||
}
|
||||
}
|
||||
const launch: NotificationTap = {
|
||||
id: "one",
|
||||
data: { serverId: "host", workspaceId: "stale", agentId: "agent" },
|
||||
};
|
||||
async function flush() {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("notification open", () => {
|
||||
it.each([
|
||||
[undefined, null],
|
||||
[null, null],
|
||||
[[], null],
|
||||
[{ agentId: "a" }, null],
|
||||
[{ serverId: " " }, null],
|
||||
[{ serverId: " h " }, { kind: "host", serverId: "h" }],
|
||||
[
|
||||
{ serverId: "h", agentId: "a" },
|
||||
{ kind: "agent", serverId: "h", agentId: "a" },
|
||||
],
|
||||
[
|
||||
{ serverId: "h", workspaceId: "stale", agentId: "a" },
|
||||
{ kind: "agent", serverId: "h", agentId: "a" },
|
||||
],
|
||||
[
|
||||
{ serverId: "h", workspaceId: "w", terminalId: "t", agentId: "a" },
|
||||
{ kind: "terminal", serverId: "h", workspaceId: "w", terminalId: "t" },
|
||||
],
|
||||
])("parses %j", (data, expected) => {
|
||||
expect(parseNotificationOpenTarget(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
function setup(whenReadyToNavigate = () => Promise.resolve()) {
|
||||
const source = new FakeSource(launch);
|
||||
const agents: unknown[] = [];
|
||||
const routes: string[] = [];
|
||||
const terminals: unknown[] = [];
|
||||
const router = createNotificationOpenRouter({
|
||||
source,
|
||||
openAgent: (target) => {
|
||||
agents.push(target);
|
||||
},
|
||||
openTerminal: (target) => {
|
||||
terminals.push(target);
|
||||
},
|
||||
openRoute: (route) => {
|
||||
routes.push(route);
|
||||
},
|
||||
whenReadyToNavigate,
|
||||
});
|
||||
return { source, agents, routes, terminals, router };
|
||||
}
|
||||
|
||||
it("takes the launch tap once across remounts", async () => {
|
||||
const { source, agents, router } = setup();
|
||||
router.start()();
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
expect(source.launchReads).toBe(1);
|
||||
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("ignores listener replay of the launch tap after remount", async () => {
|
||||
const { source, agents, router } = setup();
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
stop();
|
||||
const stopAgain = router.start();
|
||||
source.tap(launch);
|
||||
await flush();
|
||||
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
|
||||
expect(source.acknowledged).toEqual(["one"]);
|
||||
stopAgain();
|
||||
});
|
||||
|
||||
it("waits for readiness before dispatch and acknowledgement", async () => {
|
||||
let ready = () => {};
|
||||
const readiness = new Promise<void>((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
const { source, agents, router } = setup(() => readiness);
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
expect(agents).toEqual([]);
|
||||
expect(source.acknowledged).toEqual([]);
|
||||
ready();
|
||||
await flush();
|
||||
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
|
||||
expect(source.acknowledged).toEqual(["one"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("acknowledges each distinct tap including invalid targets", async () => {
|
||||
const { source, agents, router } = setup();
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
source.tap({ id: "two", data: { serverId: "host", agentId: "second" } });
|
||||
source.tap({ id: "invalid", data: undefined });
|
||||
await flush();
|
||||
expect(agents).toEqual([
|
||||
{ serverId: "host", agentId: "agent" },
|
||||
{ serverId: "host", agentId: "second" },
|
||||
]);
|
||||
expect(source.acknowledged).toEqual(["one", "two", "invalid"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("dispatches terminals and hosts through their navigation capabilities", async () => {
|
||||
const { source, routes, terminals, router } = setup();
|
||||
const stop = router.start();
|
||||
source.tap({
|
||||
id: "terminal",
|
||||
data: { serverId: "host", workspaceId: "ws", terminalId: "term" },
|
||||
});
|
||||
source.tap({ id: "host", data: { serverId: "host" } });
|
||||
await flush();
|
||||
expect(routes).toEqual(["/h/host"]);
|
||||
expect(terminals).toEqual([
|
||||
{ kind: "terminal", serverId: "host", workspaceId: "ws", terminalId: "term" },
|
||||
]);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
export type NotificationOpenTarget =
|
||||
| { kind: "agent"; serverId: string; agentId: string }
|
||||
| { kind: "terminal"; serverId: string; workspaceId: string; terminalId: string }
|
||||
| { kind: "host"; serverId: string };
|
||||
|
||||
function readId(data: object, key: string): string {
|
||||
const value: unknown = Reflect.get(data, key);
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function parseNotificationOpenTarget(data: unknown): NotificationOpenTarget | null {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
||||
const serverId = readId(data, "serverId");
|
||||
if (!serverId) return null;
|
||||
const workspaceId = readId(data, "workspaceId");
|
||||
const terminalId = readId(data, "terminalId");
|
||||
if (workspaceId && terminalId) return { kind: "terminal", serverId, workspaceId, terminalId };
|
||||
const agentId = readId(data, "agentId");
|
||||
if (agentId) return { kind: "agent", serverId, agentId };
|
||||
return { kind: "host", serverId };
|
||||
}
|
||||
|
||||
interface NotificationOpenRouterDeps {
|
||||
source: NotificationOpenSource;
|
||||
openAgent: (target: { serverId: string; agentId: string }) => void;
|
||||
openRoute: (route: string) => void;
|
||||
openTerminal: (target: Extract<NotificationOpenTarget, { kind: "terminal" }>) => void;
|
||||
whenReadyToNavigate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createNotificationOpenRouter(deps: NotificationOpenRouterDeps) {
|
||||
const handledIds = new Set<string>();
|
||||
let launchTaken = false;
|
||||
async function receive(tap: NotificationTap) {
|
||||
if (handledIds.has(tap.id)) return;
|
||||
handledIds.add(tap.id);
|
||||
await deps.whenReadyToNavigate();
|
||||
const target = parseNotificationOpenTarget(tap.data);
|
||||
if (target?.kind === "agent") {
|
||||
deps.openAgent({ serverId: target.serverId, agentId: target.agentId });
|
||||
} else if (target?.kind === "terminal") {
|
||||
deps.openTerminal(target);
|
||||
} else if (target?.kind === "host") {
|
||||
deps.openRoute(buildHostRootRoute(target.serverId));
|
||||
}
|
||||
deps.source.acknowledge(tap);
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
const unsubscribe = deps.source.subscribe((tap) => {
|
||||
void receive(tap);
|
||||
});
|
||||
if (!launchTaken) {
|
||||
launchTaken = true;
|
||||
void deps.source.takeLaunchTap().then((tap) => {
|
||||
return tap ? receive(tap) : undefined;
|
||||
});
|
||||
}
|
||||
return unsubscribe;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from "react";
|
||||
import { router, usePathname, useRootNavigationState, type Href } from "expo-router";
|
||||
import { useHostRegistryStatus } from "@/runtime/host-runtime";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { createNotificationOpenRouter } from "../model";
|
||||
import { createNotificationOpenSource } from "../source";
|
||||
|
||||
let ready = false;
|
||||
const waiters = new Set<() => void>();
|
||||
const notificationRouter = createNotificationOpenRouter({
|
||||
source: createNotificationOpenSource(),
|
||||
openAgent: (target) => navigateToAgent({ ...target, pin: true }),
|
||||
openTerminal: ({ serverId, workspaceId, terminalId }) =>
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
}),
|
||||
openRoute: (route) => router.navigate(route as Href),
|
||||
whenReadyToNavigate: () =>
|
||||
ready
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
waiters.add(resolve);
|
||||
}),
|
||||
});
|
||||
|
||||
export function NotificationOpenRouter({ startupBlocked }: { startupBlocked: boolean }) {
|
||||
const root = useRootNavigationState();
|
||||
const pathname = usePathname();
|
||||
const registryStatus = useHostRegistryStatus();
|
||||
const selectionHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
// Index routes redirect during startup. Let their final leaf commit before dispatching a tap.
|
||||
const atStartupIndex = pathname === "/" || /^\/h\/[^/]+\/?$/.test(pathname);
|
||||
const canNavigate = Boolean(
|
||||
root?.key &&
|
||||
registryStatus === "ready" &&
|
||||
selectionHydrated &&
|
||||
!startupBlocked &&
|
||||
!atStartupIndex,
|
||||
);
|
||||
useEffect(() => {
|
||||
ready = canNavigate;
|
||||
if (ready) {
|
||||
for (const resolve of waiters) resolve();
|
||||
waiters.clear();
|
||||
}
|
||||
return () => {
|
||||
ready = false;
|
||||
};
|
||||
}, [canNavigate]);
|
||||
useEffect(() => notificationRouter.start(), []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { createNotificationOpenSource as createWebSource } from "./source.web";
|
||||
import type { NotificationOpenSource } from "./source";
|
||||
|
||||
export function createNotificationOpenSource(): NotificationOpenSource {
|
||||
const web = createWebSource();
|
||||
let nextId = 0;
|
||||
return {
|
||||
...web,
|
||||
subscribe(onTap) {
|
||||
const stopWeb = web.subscribe(onTap);
|
||||
let disposed = false;
|
||||
let stopDesktop: (() => void) | undefined;
|
||||
const subscription = getDesktopHost()?.events?.on?.(
|
||||
"notification-click",
|
||||
(payload: unknown) => {
|
||||
const data =
|
||||
typeof payload === "object" && payload !== null && "data" in payload
|
||||
? payload.data
|
||||
: undefined;
|
||||
onTap({
|
||||
id: `electron:${++nextId}`,
|
||||
data:
|
||||
typeof data === "object" && data !== null
|
||||
? Object.fromEntries(Object.entries(data))
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
void Promise.resolve(subscription).then((stop) => {
|
||||
if (disposed) stop?.();
|
||||
else stopDesktop = stop;
|
||||
return;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopWeb();
|
||||
stopDesktop?.();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as Notifications from "expo-notifications";
|
||||
import type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
|
||||
function toTap(response: Notifications.NotificationResponse): NotificationTap {
|
||||
return {
|
||||
id: response.notification.request.identifier,
|
||||
data: response.notification.request.content.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function createNotificationOpenSource(): NotificationOpenSource {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: false,
|
||||
shouldShowBanner: false,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
subscribe(onTap) {
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener((response) =>
|
||||
onTap(toTap(response)),
|
||||
);
|
||||
return () => subscription.remove();
|
||||
},
|
||||
async takeLaunchTap() {
|
||||
const response = await Notifications.getLastNotificationResponseAsync();
|
||||
return response ? toTap(response) : null;
|
||||
},
|
||||
acknowledge() {
|
||||
Notifications.clearLastNotificationResponse();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface NotificationTap {
|
||||
id: string;
|
||||
data: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface NotificationOpenSource {
|
||||
subscribe(onTap: (tap: NotificationTap) => void): () => void;
|
||||
takeLaunchTap(): Promise<NotificationTap | null>;
|
||||
acknowledge(tap: NotificationTap): void;
|
||||
}
|
||||
|
||||
export { createNotificationOpenSource } from "./source.native";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { WEB_NOTIFICATION_CLICK_EVENT } from "./model";
|
||||
import type { NotificationOpenSource } from "./source";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export function createNotificationOpenSource(): NotificationOpenSource {
|
||||
let nextId = 0;
|
||||
return {
|
||||
subscribe(onTap) {
|
||||
if (!isWeb) return () => {};
|
||||
function receive(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
event.preventDefault();
|
||||
onTap({ id: `web:${++nextId}`, data: event.detail?.data });
|
||||
}
|
||||
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
|
||||
return () => window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
|
||||
},
|
||||
async takeLaunchTap() {
|
||||
return null;
|
||||
},
|
||||
acknowledge() {},
|
||||
};
|
||||
}
|
||||
@@ -1011,6 +1011,50 @@ describe("HostRuntimeController", () => {
|
||||
expect(activeClient.latencyMeasurements()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([12, null])(
|
||||
"falls back to relay when the direct client disconnects with heartbeat RTT %s",
|
||||
async (heartbeatRtt) => {
|
||||
useHostRuntimeClock();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 12,
|
||||
"relay:relay.paseo.sh:443": 65,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host: makeHost(),
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
|
||||
const directClient = clients[0];
|
||||
directClient.heartbeatReportsRtt(heartbeatRtt);
|
||||
directClient.clearLatencyMeasurements();
|
||||
latencies["direct:lan:6767"] = new Error("LAN unreachable");
|
||||
directClient.setConnectionState({ status: "disconnected", reason: "network changed" });
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("error");
|
||||
expect(directClient.getLastLivenessRttMs()).toBe(heartbeatRtt);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await controller.runProbeCycleNow();
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
expect(snapshot.probeByConnectionId).toEqual(
|
||||
new Map([
|
||||
["direct:lan:6767", { status: "unavailable", latencyMs: null }],
|
||||
["relay:relay.paseo.sh:443", { status: "available", latencyMs: 65 }],
|
||||
]),
|
||||
);
|
||||
expect(directClient.isDisposed()).toBe(true);
|
||||
expect(directClient.latencyMeasurements()).toEqual([]);
|
||||
await controller.stop();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not create a probe client for the selected connection while it reconnects", async () => {
|
||||
useHostRuntimeClock();
|
||||
const relay: HostConnection = {
|
||||
|
||||
@@ -1110,6 +1110,14 @@ export class HostRuntimeController {
|
||||
shouldCloseClient = shouldCloseClient && !activated;
|
||||
|
||||
if (activeClient) {
|
||||
if (activeClient.getConnectionState().status !== "connected") {
|
||||
probeByConnectionId.set(connection.id, {
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
});
|
||||
publishProbeState();
|
||||
return;
|
||||
}
|
||||
const rttMs = activeClient.getLastLivenessRttMs();
|
||||
if (!this.isCurrentProbeRequest(requestVersion)) {
|
||||
return;
|
||||
|
||||
@@ -139,7 +139,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.metadataGeneration.model")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.metadataGeneration.fallbackHint")}
|
||||
{t("settings.metadataGeneration.modelHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<CombinedModelSelector
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
type SheetTab,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
usePendingWorkspaceOpen,
|
||||
useWorkspaceLayoutStoreHydrated,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -271,12 +272,10 @@ interface WorkspaceScreenProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isRouteFocused?: boolean;
|
||||
recoveryRequested?: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||
isRouteFocused: boolean;
|
||||
recoveryRequested: boolean;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -419,7 +418,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
useEffect(() => {
|
||||
@@ -433,7 +431,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -748,9 +745,9 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
function shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces: boolean,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
recoveryRequested: boolean,
|
||||
pendingOpenTarget: WorkspaceTabTarget | null,
|
||||
): boolean {
|
||||
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
|
||||
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
|
||||
}
|
||||
|
||||
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
@@ -1015,7 +1012,6 @@ function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1149,13 +1145,18 @@ function WorkspaceScreenContent({
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const pendingOpenTarget = usePendingWorkspaceOpen({
|
||||
workspaceKey: persistenceKey,
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceDescriptor !== null,
|
||||
});
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
enabled: shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces,
|
||||
workspaceDescriptor,
|
||||
recoveryRequested,
|
||||
pendingOpenTarget,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
||||
getSessionAgents: (serverId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
|
||||
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
|
||||
deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input),
|
||||
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
|
||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||
navigateToRoute: (route) => {
|
||||
|
||||
@@ -21,7 +21,12 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
const navigations: string[] = [];
|
||||
const remembered: ActiveWorkspaceSelection[] = [];
|
||||
const openedTabs: RecordedTab[] = [];
|
||||
const deferredTabs: unknown[] = [];
|
||||
const deps: NavigateToWorkspaceDeps = {
|
||||
deferOpenTab: (input) => {
|
||||
deferredTabs.push(input);
|
||||
return null;
|
||||
},
|
||||
getSessionWorkspaces: () => null,
|
||||
getSessionAgents: () => [] as Agent[],
|
||||
isWorkspaceLayoutHydrated: () => true,
|
||||
@@ -33,7 +38,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
navigateToRoute: (route) => navigations.push(route),
|
||||
...overrides,
|
||||
};
|
||||
return { deps, navigations, remembered, openedTabs };
|
||||
return { deps, navigations, remembered, openedTabs, deferredTabs };
|
||||
}
|
||||
|
||||
function createLastSelectionDeps(
|
||||
@@ -139,7 +144,7 @@ describe("workspace navigation", () => {
|
||||
});
|
||||
|
||||
it("defers an agent tab until a missing workspace is recovered", () => {
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map(),
|
||||
});
|
||||
|
||||
@@ -153,7 +158,8 @@ describe("workspace navigation", () => {
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
|
||||
expect(deferredTabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("defers an agent tab until persisted workspace layout has hydrated", () => {
|
||||
@@ -161,7 +167,7 @@ describe("workspace navigation", () => {
|
||||
id: "workspace-a",
|
||||
workspaceDirectory: "/repo/workspace-a",
|
||||
} as WorkspaceDescriptor;
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
|
||||
isWorkspaceLayoutHydrated: () => false,
|
||||
});
|
||||
@@ -177,7 +183,8 @@ describe("workspace navigation", () => {
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
|
||||
expect(deferredTabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
import {
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
|
||||
import type { OpenWorkspaceTabInput } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
|
||||
|
||||
export interface RouteSelectionInput {
|
||||
@@ -35,6 +35,7 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
||||
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
|
||||
getSessionAgents: (serverId: string) => Iterable<Agent>;
|
||||
isWorkspaceLayoutHydrated: () => boolean;
|
||||
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
|
||||
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
|
||||
navigateToRoute: (route: string) => void;
|
||||
}
|
||||
@@ -91,13 +92,10 @@ export function navigateToWorkspace(
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const shouldDeferAgentOpen = Boolean(
|
||||
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
|
||||
);
|
||||
const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated();
|
||||
if (input.target) {
|
||||
if (!shouldDeferAgentOpen) {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
}
|
||||
const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab;
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
|
||||
} else {
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(input.serverId)).filter(
|
||||
@@ -114,14 +112,7 @@ export function navigateToWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
const route =
|
||||
input.target?.kind === "agent" && shouldDeferAgentOpen
|
||||
? buildHostWorkspaceOpenRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
`agent:${input.target.agentId}`,
|
||||
)
|
||||
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
|
||||
deps.navigateToRoute(route);
|
||||
return route;
|
||||
|
||||
@@ -1192,7 +1192,10 @@ export function restoreWorkspaceLayout(
|
||||
const normalized = normalizeLayout(layout);
|
||||
let root = asInternalNode(normalized.root);
|
||||
let focusedPaneId = normalized.focusedPaneId;
|
||||
if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) {
|
||||
const visibleOrdinaryPane = collectAllPanes(root).find(
|
||||
(pane) => pane.id !== explorerSidebarPaneId,
|
||||
);
|
||||
if (!visibleOrdinaryPane) {
|
||||
const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId);
|
||||
if (ordinaryPane) {
|
||||
root = updatePaneInTree(root, {
|
||||
@@ -1214,6 +1217,9 @@ export function restoreWorkspaceLayout(
|
||||
sizes: [0.78, 0.22],
|
||||
});
|
||||
}
|
||||
} else if (focusedPaneId === null) {
|
||||
// Parked focus has an in-memory restoration record that cannot survive reload.
|
||||
focusedPaneId = visibleOrdinaryPane.id;
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
|
||||
import { expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import {
|
||||
createWorkspaceLayoutStore,
|
||||
collectAllTabs,
|
||||
selectExplorerSidebarPaneId,
|
||||
} from "./workspace-layout-store";
|
||||
import { deriveWorkspacePaneState } from "../screens/workspace/workspace-pane-state";
|
||||
import { selectVisibleAgentIds } from "../screens/workspace/visible-agent-ids";
|
||||
|
||||
Object.assign(globalThis, { indexedDB, IDBKeyRange });
|
||||
|
||||
@@ -88,3 +94,60 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
|
||||
saved.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"restores the agent pane after parked focus in storage version %i",
|
||||
async (version) => {
|
||||
const values = new Map<string, string>();
|
||||
const storage: StateStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
const key = "host:workspace";
|
||||
const source = createWorkspaceLayoutStore(undefined, storage);
|
||||
await source.persist.rehydrate();
|
||||
source.getState().setLayoutMode("compact");
|
||||
source.getState().openTab({
|
||||
workspaceKey: key,
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
intent: "reveal",
|
||||
});
|
||||
source.getState().unfocusPane(key);
|
||||
expect(source.getState().layoutByWorkspace[key].focusedPaneId).toBeNull();
|
||||
const persisted = JSON.parse(values.get("workspace-layout-state")!);
|
||||
persisted.version = version;
|
||||
values.set("workspace-layout-state", JSON.stringify(persisted));
|
||||
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||
restored.getState().setLayoutMode("compact");
|
||||
await restored.persist.rehydrate();
|
||||
restored.getState().reconcileTabs(key, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-a"],
|
||||
autoOpenAgentIds: ["agent-a"],
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
const layout = restored.getState().layoutByWorkspace[key];
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
const restoredExplorerId = selectExplorerSidebarPaneId(restored.getState(), key);
|
||||
const main = deriveWorkspacePaneState({ layout, tabs });
|
||||
const visible = selectVisibleAgentIds({
|
||||
layout,
|
||||
tabs,
|
||||
routeFocused: true,
|
||||
focusedPaneOnly: true,
|
||||
explorerPaneId: restoredExplorerId,
|
||||
explorerVisible: false,
|
||||
});
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(main.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" });
|
||||
expect(main.tabs.map((t) => t.descriptor.kind)).toEqual(["agent"]);
|
||||
expect(visible).toEqual(["agent-a"]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5131,3 +5131,43 @@ describe("compact workspace sheet", () => {
|
||||
expect(received).toEqual([[], ["child"], []]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending workspace opens", () => {
|
||||
it("retains an open across hydration and consumes it once only for its workspace", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const store = createWorkspaceLayoutStore(undefined, {
|
||||
getItem: async (key) => values.get(key) ?? null,
|
||||
setItem: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
});
|
||||
store.getState().deferOpenTab({
|
||||
workspaceKey: "server:beta",
|
||||
target: { kind: "agent", agentId: "b" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
await store.persist.rehydrate();
|
||||
expect(store.getState().consumePendingOpen("server:alpha")).toBeNull();
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.getWorkspaceTabs("server:alpha")
|
||||
.filter((tab) => tab.target.kind === "agent"),
|
||||
).toEqual([]);
|
||||
expect(store.getState().consumePendingOpen("server:beta")).toBe("agent_b");
|
||||
expect(store.getState().consumePendingOpen("server:beta")).toBeNull();
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.getWorkspaceTabs("server:beta")
|
||||
.filter((tab) => tab.target.kind === "agent")
|
||||
.map((tab) => tab.target),
|
||||
).toEqual([{ kind: "agent", agentId: "b" }]);
|
||||
expect(store.getState().pinnedAgentIdsByWorkspace["server:beta"]).toEqual(new Set(["b"]));
|
||||
expect(store.getState().pendingOpenByWorkspace).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import type { z } from "zod";
|
||||
@@ -130,6 +130,9 @@ export type WorkspaceTabDestination =
|
||||
interface WorkspaceLayoutStore {
|
||||
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
||||
layoutMode: WorkspaceLayoutMode;
|
||||
pendingOpenByWorkspace: Record<string, OpenWorkspaceTabInput>;
|
||||
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
|
||||
consumePendingOpen: (workspaceKey: string) => string | null;
|
||||
/**
|
||||
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
|
||||
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
|
||||
@@ -1020,6 +1023,26 @@ export function createWorkspaceLayoutStore(
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
pendingOpenByWorkspace: {},
|
||||
deferOpenTab: (input) => {
|
||||
set((state) => ({
|
||||
pendingOpenByWorkspace: {
|
||||
...state.pendingOpenByWorkspace,
|
||||
[input.workspaceKey]: input,
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
},
|
||||
consumePendingOpen: (workspaceKey) => {
|
||||
const pending = get().pendingOpenByWorkspace[workspaceKey];
|
||||
if (!pending) return null;
|
||||
set((state) => {
|
||||
const { [workspaceKey]: _pending, ...pendingOpenByWorkspace } =
|
||||
state.pendingOpenByWorkspace;
|
||||
return { pendingOpenByWorkspace };
|
||||
});
|
||||
return get().openTab(pending);
|
||||
},
|
||||
layoutMode: "split-panes",
|
||||
sheetsByWorkspace: {},
|
||||
layoutByWorkspace: {},
|
||||
@@ -2160,6 +2183,7 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
set((state) => {
|
||||
const hasAny =
|
||||
normalizedWorkspaceKey in state.pendingOpenByWorkspace ||
|
||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
|
||||
@@ -2173,6 +2197,8 @@ export function createWorkspaceLayoutStore(
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
const { [normalizedWorkspaceKey]: _pendingOpen, ...pendingOpenByWorkspace } =
|
||||
state.pendingOpenByWorkspace;
|
||||
const {
|
||||
[normalizedWorkspaceKey]: _autoOpened,
|
||||
...pullRequestTabAutoOpenedByWorkspace
|
||||
@@ -2199,6 +2225,7 @@ export function createWorkspaceLayoutStore(
|
||||
state.sidePaneIdByWorkspace;
|
||||
return {
|
||||
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
|
||||
pendingOpenByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace,
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -2348,3 +2375,27 @@ export function useWorkspaceLayoutStoreHydrated(): boolean {
|
||||
|
||||
return hasHydrated;
|
||||
}
|
||||
|
||||
interface PendingWorkspaceOpenReadiness {
|
||||
workspaceKey: string | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
workspaceExists: boolean;
|
||||
}
|
||||
|
||||
export function usePendingWorkspaceOpen({
|
||||
workspaceKey,
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists,
|
||||
}: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null {
|
||||
const workspaceReady = hasHydratedWorkspaces && workspaceExists;
|
||||
const pending = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.pendingOpenByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const layoutHydrated = useWorkspaceLayoutStoreHydrated();
|
||||
useLayoutEffect(() => {
|
||||
if (workspaceKey && workspaceReady && layoutHydrated && pending) {
|
||||
useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey);
|
||||
}
|
||||
}, [workspaceKey, workspaceReady, layoutHydrated, pending]);
|
||||
return pending ? pending.target : null;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
it("uses the input workspaceId only when the store has no record", () => {
|
||||
const readTargets: { serverId: string; agentId: string }[] = [];
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
|
||||
deps.readAgentNavTarget = (input) => {
|
||||
@@ -75,7 +75,7 @@ describe("resolveNavigateToAgent", () => {
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(readTargets).toEqual([]);
|
||||
expect(readTargets).toEqual([{ serverId: SERVER_ID, agentId: AGENT_ID }]);
|
||||
expect(tabNavigations).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
@@ -86,6 +86,12 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers the current agent workspace over a stale input workspace", () => {
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: "current" });
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: "stale" }, deps);
|
||||
expect(tabNavigations[0]?.workspaceId).toBe("current");
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -26,8 +26,8 @@ export function resolveNavigateToAgent(
|
||||
deps: NavigateToAgentDeps,
|
||||
): string {
|
||||
const agentWorkspaceId =
|
||||
input.workspaceId ??
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId })
|
||||
.agentWorkspaceId ?? input.workspaceId;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (!workspaceId) {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
|
||||
describe("resolveNotificationTarget", () => {
|
||||
it("extracts non-empty server and agent ids", () => {
|
||||
expect(
|
||||
resolveNotificationTarget({
|
||||
serverId: " server-123 ",
|
||||
agentId: " agent-456 ",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "server-123",
|
||||
agentId: "agent-456",
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for missing/empty ids", () => {
|
||||
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
expect(resolveNotificationTarget(undefined)).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat cwd as a workspace id alias", () => {
|
||||
expect(
|
||||
resolveNotificationTarget({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotificationRoute", () => {
|
||||
it("routes to the agent path when workspace id is present", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "ws-main",
|
||||
}),
|
||||
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
|
||||
});
|
||||
|
||||
it("does not treat an incomplete notification as an agent URL", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1");
|
||||
});
|
||||
|
||||
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
workspaceId: "ws-main",
|
||||
terminalId: "term-1",
|
||||
}),
|
||||
).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1");
|
||||
});
|
||||
|
||||
it("falls back to host root for a terminal without a workspace id", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
terminalId: "term-1",
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
).toBe("/h/srv-1");
|
||||
});
|
||||
|
||||
it("falls back to host root when only serverId is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only");
|
||||
});
|
||||
|
||||
it("falls back to root when no server id is present", () => {
|
||||
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/");
|
||||
expect(buildNotificationRoute(undefined)).toBe("/");
|
||||
});
|
||||
|
||||
it("encodes path segments", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv/with/slash",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent with space",
|
||||
}),
|
||||
).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space");
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Href } from "expo-router";
|
||||
import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes";
|
||||
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
type NotificationRoute = Extract<Href, string>;
|
||||
|
||||
function readNonEmptyString(data: NotificationData, key: string): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveNotificationTarget(data: NotificationData): {
|
||||
serverId: string | null;
|
||||
agentId: string | null;
|
||||
workspaceId: string | null;
|
||||
terminalId: string | null;
|
||||
} {
|
||||
return {
|
||||
serverId: readNonEmptyString(data, "serverId"),
|
||||
agentId: readNonEmptyString(data, "agentId"),
|
||||
workspaceId: readNonEmptyString(data, "workspaceId"),
|
||||
terminalId: readNonEmptyString(data, "terminalId"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
||||
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
|
||||
if (serverId && workspaceId && agentId) {
|
||||
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`);
|
||||
}
|
||||
if (serverId && workspaceId && terminalId) {
|
||||
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
|
||||
}
|
||||
if (serverId) {
|
||||
return buildHostRootRoute(serverId);
|
||||
}
|
||||
return "/" as const;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface MockNotificationOptions {
|
||||
@@ -132,7 +133,7 @@ describe("sendOsNotification", () => {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
@@ -159,7 +160,7 @@ describe("sendOsNotification", () => {
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to route navigation when no listener handles the click", async () => {
|
||||
it("leaves navigation to the app when no listener handles the click", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
@@ -207,9 +208,7 @@ describe("sendOsNotification", () => {
|
||||
expect(clicked.clickListeners).toHaveLength(1);
|
||||
clicked.clickListeners[0]?.({} as Event);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith(
|
||||
"/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1",
|
||||
);
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when the Notification API is unavailable", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
import { parseNotificationOpenTarget, WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface OsNotificationPayload {
|
||||
@@ -17,8 +17,6 @@ interface WebNotificationInstance {
|
||||
addEventListener: (type: "click", listener: (event: Event) => void) => void;
|
||||
}
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
let notificationIconUrl: string | null | undefined;
|
||||
|
||||
@@ -98,8 +96,7 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
|
||||
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
|
||||
const target = resolveNotificationTarget(data);
|
||||
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||
return parseNotificationOpenTarget(data) !== null;
|
||||
}
|
||||
|
||||
function getWebNotificationIconUrl(): string | undefined {
|
||||
@@ -135,31 +132,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
|
||||
return !dispatch(event);
|
||||
}
|
||||
|
||||
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
|
||||
const route = buildNotificationRoute(data);
|
||||
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
|
||||
.location;
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (typeof location.assign === "function") {
|
||||
location.assign(route);
|
||||
return;
|
||||
}
|
||||
if (typeof location.href === "string") {
|
||||
location.href = route;
|
||||
}
|
||||
}
|
||||
|
||||
function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): void {
|
||||
notification.addEventListener("click", () => {
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
dispatchWebNotificationClick({ data });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,57 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("logs the provider, model and error of every failed attempt", async () => {
|
||||
const warnings: Array<{ obj: object; msg?: string }> = [];
|
||||
const runnerError = new Error("connection refused");
|
||||
const manager = createManager([
|
||||
{ provider: "local-llm", available: true, error: null },
|
||||
{ provider: "opencode", available: false, error: "not installed" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
schemaName: "BranchName",
|
||||
providers: [
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "opencode", model: "minimax-m3" },
|
||||
],
|
||||
logger: {
|
||||
info: () => undefined,
|
||||
warn: (obj, msg) => warnings.push({ obj, msg }),
|
||||
},
|
||||
runner: async () => {
|
||||
throw runnerError;
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
obj: {
|
||||
err: runnerError,
|
||||
provider: "local-llm",
|
||||
model: "qwen-local",
|
||||
schemaName: "BranchName",
|
||||
},
|
||||
msg: "Structured generation: provider failed",
|
||||
},
|
||||
{
|
||||
obj: {
|
||||
provider: "opencode",
|
||||
model: "minimax-m3",
|
||||
schemaName: "BranchName",
|
||||
reason: "not installed",
|
||||
},
|
||||
msg: "Structured generation: skipping unavailable provider",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
|
||||
@@ -475,7 +475,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
});
|
||||
logger?.warn(
|
||||
{ err: error, provider: candidate.provider, model: candidate.model, schemaName },
|
||||
"Structured generation: provider failed, trying next",
|
||||
"Structured generation: provider failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProviderSnapshots {
|
||||
}
|
||||
|
||||
describe("resolveStructuredGenerationProviders", () => {
|
||||
test("tries the configured model before dynamically discovered fallbacks", async () => {
|
||||
test("tries only the configured models when metadata generation is manual", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "work-claude",
|
||||
@@ -28,6 +28,12 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
{ provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [{ provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true }],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
@@ -35,14 +41,18 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
providerSnapshotManager: snapshots,
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "mock", model: "ten-second-stream" }],
|
||||
providers: [
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
],
|
||||
},
|
||||
},
|
||||
currentSelection: { provider: "opencode", model: "minimax-m3" },
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "work-claude", model: "claude-haiku-2026" },
|
||||
]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
@@ -227,10 +237,7 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "current-provider", model: "configured-model" },
|
||||
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface ResolveStructuredGenerationProvidersOptions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-empty configured list means Manual: only those models are tried, in order.
|
||||
* An empty list means Automatic: built-in defaults, then the current selection.
|
||||
*/
|
||||
export async function resolveStructuredGenerationProviders(
|
||||
options: ResolveStructuredGenerationProvidersOptions,
|
||||
): Promise<StructuredGenerationProvider[]> {
|
||||
@@ -53,16 +57,18 @@ export async function resolveStructuredGenerationProviders(
|
||||
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
|
||||
for (const configured of configuredProviders) {
|
||||
const resolvedConfigured = resolveConfiguredCandidate(
|
||||
configured,
|
||||
modelEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (!resolvedConfigured) {
|
||||
continue;
|
||||
if (configuredProviders.length > 0) {
|
||||
for (const configured of configuredProviders) {
|
||||
const resolvedConfigured = resolveConfiguredCandidate(
|
||||
configured,
|
||||
modelEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (resolvedConfigured) {
|
||||
providers.push(resolvedConfigured);
|
||||
}
|
||||
}
|
||||
providers.push(resolvedConfigured);
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
|
||||
for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) {
|
||||
|
||||
@@ -973,7 +973,9 @@ export class Session {
|
||||
providerSnapshotManager,
|
||||
readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(),
|
||||
getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd),
|
||||
logger: this.sessionLogger,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
}),
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../../../utils/che
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import {
|
||||
createGitMetadataGenerator,
|
||||
type GitMetadataGenerator,
|
||||
type StructuredTextGeneration,
|
||||
type StructuredTextGenerationRequest,
|
||||
} from "./git-metadata-generator.js";
|
||||
@@ -38,6 +39,18 @@ function createGeneration(handler: (request: StructuredTextGenerationRequest<unk
|
||||
return { generation, generateCalls };
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const errors: Array<{ obj: object; msg?: string }> = [];
|
||||
return {
|
||||
errors,
|
||||
logger: { error: (obj: object, msg?: string) => errors.push({ obj, msg }) },
|
||||
};
|
||||
}
|
||||
|
||||
const MANUAL_MODEL_FAILURE = new StructuredAgentFallbackError([
|
||||
{ provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" },
|
||||
]);
|
||||
|
||||
const DIFF_WITH_ONE_FILE: CheckoutDiffResult = {
|
||||
diff: "diff --git a/src/foo.ts b/src/foo.ts\n+added\n",
|
||||
structured: [
|
||||
@@ -59,7 +72,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation, generateCalls } = createGeneration(() => ({
|
||||
message: "Fix the flaky retry test",
|
||||
}));
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
const message = await generator.generateCommitMessage("/repo");
|
||||
|
||||
@@ -82,7 +99,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files");
|
||||
});
|
||||
@@ -95,7 +116,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
validationErrors: ["message: required"],
|
||||
});
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files");
|
||||
});
|
||||
@@ -105,7 +130,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).rejects.toThrow("network down");
|
||||
});
|
||||
@@ -116,7 +145,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
title: "Add retry with backoff",
|
||||
body: "Retries transient failures up to twice.",
|
||||
}));
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
const result = await generator.generatePullRequestText("/repo", "main");
|
||||
|
||||
@@ -140,11 +173,60 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generatePullRequestText("/repo")).resolves.toEqual({
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"generateCommitMessage",
|
||||
"CommitMessage",
|
||||
(generator: GitMetadataGenerator) => generator.generateCommitMessage("/repo"),
|
||||
],
|
||||
[
|
||||
"generatePullRequestText",
|
||||
"PullRequest",
|
||||
(generator: GitMetadataGenerator) => generator.generatePullRequestText("/repo", "main"),
|
||||
],
|
||||
] as const)("%s logs the failed provider, model and error", async (_name, schemaName, run) => {
|
||||
const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation } = createGeneration(() => {
|
||||
throw MANUAL_MODEL_FAILURE;
|
||||
});
|
||||
const { logger, errors } = createLogger();
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger,
|
||||
});
|
||||
|
||||
await run(generator);
|
||||
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
obj: {
|
||||
err: MANUAL_MODEL_FAILURE,
|
||||
cwd: "/repo",
|
||||
schemaName,
|
||||
attempts: [
|
||||
{
|
||||
provider: "local-llm",
|
||||
model: "qwen-local",
|
||||
available: true,
|
||||
error: "connection refused",
|
||||
},
|
||||
],
|
||||
},
|
||||
msg: "Structured git metadata generation failed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
type StructuredGenerationLogger,
|
||||
} from "../../agent/agent-response-loop.js";
|
||||
import type { AgentManager } from "../../agent/agent-manager.js";
|
||||
import type { ProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js";
|
||||
@@ -37,7 +38,7 @@ export interface GitMetadataGenerator {
|
||||
* behaviour is unit-testable with a fake. Production wires
|
||||
* createAgentStructuredTextGeneration (resolve providers → run structured
|
||||
* generation); a failed generation throws StructuredAgent*Error, which the
|
||||
* generator catches and turns into the product fallback text.
|
||||
* generator logs and turns into the product fallback text.
|
||||
*/
|
||||
export interface StructuredTextGeneration {
|
||||
generate<T>(request: StructuredTextGenerationRequest<T>): Promise<T>;
|
||||
@@ -51,6 +52,10 @@ export interface StructuredTextGenerationRequest<T> {
|
||||
agentTitle: string;
|
||||
}
|
||||
|
||||
interface GitMetadataLogger {
|
||||
error: (obj: object, msg?: string) => void;
|
||||
}
|
||||
|
||||
type GitMetadataDiffSource = Pick<WorkspaceGitService, "getCheckoutDiff" | "resolveRepoRoot">;
|
||||
type CheckoutDiffOptions = Parameters<GitMetadataDiffSource["getCheckoutDiff"]>[1];
|
||||
type CheckoutDiff = Awaited<ReturnType<GitMetadataDiffSource["getCheckoutDiff"]>>;
|
||||
@@ -90,8 +95,32 @@ interface PromptForDiffInput {
|
||||
export function createGitMetadataGenerator(deps: {
|
||||
workspaceGitService: GitMetadataDiffSource;
|
||||
generation: StructuredTextGeneration;
|
||||
logger: GitMetadataLogger;
|
||||
}): GitMetadataGenerator {
|
||||
const { workspaceGitService, generation } = deps;
|
||||
const { workspaceGitService, generation, logger } = deps;
|
||||
|
||||
async function generateOrFallback<T>(
|
||||
request: StructuredTextGenerationRequest<T>,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await generation.generate(request);
|
||||
} catch (error) {
|
||||
if (!isStructuredGenerationFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: request.cwd,
|
||||
schemaName: request.schemaName,
|
||||
attempts: error instanceof StructuredAgentFallbackError ? error.attempts : undefined,
|
||||
},
|
||||
"Structured git metadata generation failed",
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPromptForDiff(input: PromptForDiffInput): Promise<string> {
|
||||
const diff = await workspaceGitService.getCheckoutDiff(input.cwd, input.diffOptions);
|
||||
@@ -123,21 +152,17 @@ export function createGitMetadataGenerator(deps: {
|
||||
styleDefault: "Concise, imperative mood, no trailing period.",
|
||||
jsonFieldsHint: "Return JSON only with a single field 'message'.",
|
||||
});
|
||||
try {
|
||||
const result = await generation.generate({
|
||||
const result = await generateOrFallback(
|
||||
{
|
||||
cwd,
|
||||
prompt,
|
||||
schema: COMMIT_MESSAGE_SCHEMA,
|
||||
schemaName: "CommitMessage",
|
||||
agentTitle: "Commit generator",
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (isStructuredGenerationFailure(error)) {
|
||||
return COMMIT_MESSAGE_FALLBACK;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ message: COMMIT_MESSAGE_FALLBACK },
|
||||
);
|
||||
return result.message;
|
||||
},
|
||||
|
||||
async generatePullRequestText(cwd, baseRef) {
|
||||
@@ -150,20 +175,16 @@ export function createGitMetadataGenerator(deps: {
|
||||
styleDefault: "Clear, descriptive title; body explaining what changed and why.",
|
||||
jsonFieldsHint: "Return JSON only with fields 'title' and 'body'.",
|
||||
});
|
||||
try {
|
||||
return await generation.generate({
|
||||
return generateOrFallback(
|
||||
{
|
||||
cwd,
|
||||
prompt,
|
||||
schema: PULL_REQUEST_SCHEMA,
|
||||
schemaName: "PullRequest",
|
||||
agentTitle: "PR generator",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStructuredGenerationFailure(error)) {
|
||||
return PULL_REQUEST_FALLBACK;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
PULL_REQUEST_FALLBACK,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -180,6 +201,7 @@ export function createAgentStructuredTextGeneration(deps: {
|
||||
getFocusedSelection: (
|
||||
cwd: string,
|
||||
) => ResolveStructuredGenerationProvidersOptions["currentSelection"];
|
||||
logger: StructuredGenerationLogger;
|
||||
}): StructuredTextGeneration {
|
||||
return {
|
||||
async generate({ cwd, prompt, schema, schemaName, agentTitle }) {
|
||||
@@ -198,6 +220,7 @@ export function createAgentStructuredTextGeneration(deps: {
|
||||
maxRetries: 2,
|
||||
providers,
|
||||
persistSession: false,
|
||||
logger: deps.logger,
|
||||
agentConfigOverrides: {
|
||||
title: agentTitle,
|
||||
internal: true,
|
||||
@@ -234,7 +257,9 @@ function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A"
|
||||
return "M";
|
||||
}
|
||||
|
||||
function isStructuredGenerationFailure(error: unknown): boolean {
|
||||
function isStructuredGenerationFailure(
|
||||
error: unknown,
|
||||
): error is StructuredAgentResponseError | StructuredAgentFallbackError {
|
||||
return (
|
||||
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { StructuredAgentGenerationWithFallbackOptions } from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
StructuredAgentFallbackError,
|
||||
type StructuredAgentGenerationWithFallbackOptions,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
type AttemptFirstAgentBranchAutoNameResult,
|
||||
@@ -227,6 +230,52 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("tries only the manual model and logs its failure", async () => {
|
||||
const logger = createLogger();
|
||||
const failure = new StructuredAgentFallbackError([
|
||||
{ provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" },
|
||||
]);
|
||||
const calls: StructuredAgentGenerationWithFallbackOptions<unknown>[] = [];
|
||||
|
||||
const result = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
daemonConfig: {
|
||||
metadataGeneration: { providers: [{ provider: "local-llm", model: "qwen-local" }] },
|
||||
},
|
||||
currentSelection: { provider: "opencode", model: "minimax-m3" },
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger,
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: async (options) => {
|
||||
calls.push(options as StructuredAgentGenerationWithFallbackOptions<unknown>);
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(calls.map((call) => call.providers)).toEqual([
|
||||
[{ provider: "local-llm", model: "qwen-local" }],
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: failure, attempts: failure.attempts },
|
||||
"Structured branch name generation failed",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["paseo.json missing", undefined],
|
||||
["paseo.json exists but invalid JSON", "{ nope"],
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-primary));
|
||||
}
|
||||
|
||||
.changelog-release-notes code {
|
||||
.changelog-release-notes :not(pre) > code {
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
@@ -522,6 +522,7 @@
|
||||
width: max(22%, 7.5rem);
|
||||
}
|
||||
|
||||
.changelog-release-notes pre,
|
||||
.docs-prose pre {
|
||||
background-color: var(--color-card) !important;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -540,6 +541,7 @@
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.changelog-release-notes pre code,
|
||||
.docs-prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
@@ -15,8 +15,9 @@ The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo,
|
||||
wrapping. It reads the file once with `readText`, saves 500 ms after the last edit with
|
||||
`writeText`, and reports unsaved edits with `tab.setModified`. When the file changes on disk with
|
||||
nothing unsaved, it reads again; with unsaved edits, or when a write rejects with
|
||||
`PluginFileConflictError`, it offers **Reload** and **Overwrite**. The bar's switcher still offers
|
||||
Paseo's Markdown and Source panels. Native shows selectable, read-only text through `useFileText`.
|
||||
`PluginFileConflictError`, it offers **Reload** and **Overwrite**. **Open with…** in the tab menu
|
||||
switches the tab back to Paseo's Markdown panel. Native shows selectable, read-only text through
|
||||
`useFileText`.
|
||||
|
||||
Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native
|
||||
path uses React Native primitives. DOM integration lives in `client/web.ts`.
|
||||
|
||||
@@ -21,29 +21,30 @@ A workspace title and its branch name are produced together from the same prompt
|
||||
|
||||
## How a model is chosen
|
||||
|
||||
You don't have to configure anything — Paseo picks a model automatically. It builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next.
|
||||
Paseo has two modes. **Automatic** is the default and needs no configuration. **Manual** uses only the models you choose.
|
||||
|
||||
The candidate list is assembled in this order:
|
||||
In Automatic mode, Paseo builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next:
|
||||
|
||||
1. **Providers you configured**, in the order you list them (see below).
|
||||
2. **Built-in defaults**, matched against the models of the providers you have enabled:
|
||||
1. **Built-in defaults**, matched against the models of the providers you have enabled:
|
||||
1. a `haiku` model
|
||||
2. `gpt-5.4-mini` (low reasoning)
|
||||
3. `minimax-m3`
|
||||
4. `nemotron-3-super`
|
||||
3. **The model currently selected** for that agent or draft, as a last resort.
|
||||
2. **The model currently selected** for that agent or draft, as a last resort.
|
||||
|
||||
Each default is a match on the model id or name, so the first enabled provider that ships a matching model wins. Anything that can't be resolved against an enabled provider is skipped. Duplicates are removed, then generation tries the list top to bottom.
|
||||
Each default is a match on the model id or name, so the first enabled provider that ships a matching model wins. Defaults with no match are skipped. Duplicates are removed, then generation tries the list top to bottom. The default order prefers small, fast, cheap models for these short tasks before falling back to whatever you have selected.
|
||||
|
||||
The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected.
|
||||
In Manual mode, Paseo tries only your configured models, in your order. It never adds the built-in defaults or the selected model, so your prompts and diffs only go to providers you chose. If every configured model fails, generation fails: workspace titles and branch names are not generated, and commits and pull requests get placeholder text. Pick a model that works for these tasks.
|
||||
|
||||
Every failure is written to `$PASEO_HOME/daemon.log` (`~/.paseo/daemon.log` by default) with the provider, the model and the error. Look there when a manual model doesn't produce titles, branch names, commit messages or pull request text.
|
||||
|
||||
## Choose a model
|
||||
|
||||
Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list.
|
||||
Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, pick the model Paseo uses. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. Switching back to Automatic clears the list.
|
||||
|
||||
## Configure a custom fallback order
|
||||
|
||||
To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
|
||||
To use more than one model in Manual mode, list them in `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Paseo tries your entries in order and nothing else. An empty list means Automatic.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -19,29 +19,30 @@ Paseo 会生成以下几类元数据:
|
||||
|
||||
## 如何选择模型 {#how-a-model-is-chosen}
|
||||
|
||||
你无需进行任何配置,Paseo 会自动选择模型。它会构建一个有序的候选列表,逐个尝试,直到生成成功,因此慢或不可用的模型会让位给下一个。
|
||||
Paseo 有两种模式。**自动** 是默认模式,无需任何配置。**手动** 只使用你选择的模型。
|
||||
|
||||
候选列表按以下顺序组成:
|
||||
在自动模式下,Paseo 会构建一个有序的候选列表,逐个尝试,直到生成成功,因此慢或不可用的模型会让位给下一个:
|
||||
|
||||
1. **你配置的 Provider**,按你列出的顺序(见下文)。
|
||||
2. **内置默认值**,与你已启用的 Provider 的模型进行匹配:
|
||||
1. **内置默认值**,与你已启用的 Provider 的模型进行匹配:
|
||||
1. 一个 `haiku` 模型
|
||||
2. `gpt-5.4-mini`(低推理)
|
||||
3. `minimax-m3`
|
||||
4. `nemotron-3-super`
|
||||
3. 最后的备选:该 Agent 或草稿**当前选中的模型**。
|
||||
2. 最后的备选:该 Agent 或草稿**当前选中的模型**。
|
||||
|
||||
每个默认值按模型 id 或名称匹配,因此第一个提供匹配模型的已启用 Provider 胜出。无法在已启用 Provider 中解析的条目会被跳过。去除重复项后,生成过程从上到下依次尝试。
|
||||
每个默认值按模型 id 或名称匹配,因此第一个提供匹配模型的已启用 Provider 胜出。没有匹配的默认值会被跳过。去除重复项后,生成过程从上到下依次尝试。默认顺序的意图是:对于这些简短任务,优先使用小、快、便宜的模型,最后才退回到你选中的模型。
|
||||
|
||||
默认顺序的意图是:对于这些简短任务,优先使用小、快、便宜的模型,最后才退回到你选中的模型。
|
||||
在手动模式下,Paseo 只按你的顺序尝试你配置的模型。它不会加入内置默认值或当前选中的模型,因此你的提示词和 diff 只会发送给你选择的 Provider。如果所有配置的模型都失败,生成就会失败:不会生成工作区标题和分支名,提交和拉取请求会使用占位文本。请选择适合这些任务的模型。
|
||||
|
||||
每次失败都会写入 `$PASEO_HOME/daemon.log`(默认是 `~/.paseo/daemon.log`),包含 Provider、模型和错误。如果手动模型没有生成标题、分支名、提交信息或拉取请求文本,请查看这里。
|
||||
|
||||
## 选择模型 {#choose-a-model}
|
||||
|
||||
打开 **设置 → 主机 → 元数据**,选择 **自动** 或 **手动**。在手动模式下,Paseo 会先尝试你选择的模型,然后退回到其余已配置的模型和内置候选。选择模型会替换 `agents.metadataGeneration.providers` 中的第一项,并保留列表的其余部分。
|
||||
打开 **设置 → 主机 → 元数据**,选择 **自动** 或 **手动**。在手动模式下,选择 Paseo 使用的模型。选择模型会替换 `agents.metadataGeneration.providers` 中的第一项,并保留列表的其余部分。切换回自动会清空该列表。
|
||||
|
||||
## 配置自定义的回退顺序 {#configure-a-custom-fallback-order}
|
||||
|
||||
如果要配置多个首选模型或控制确切的顺序,请在 `~/.paseo/config.json` 中设置 `agents.metadataGeneration.providers`。你的条目会在内置默认值之前尝试。
|
||||
如果要在手动模式下使用多个模型,请在 `~/.paseo/config.json` 的 `agents.metadataGeneration.providers` 中列出它们。Paseo 按顺序尝试你的条目,不会尝试其他模型。空列表表示自动。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1885,8 +1885,8 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC
|
||||
|
||||
Register a component that renders a file as the body of its file tab. Your component receives a
|
||||
handle to the file on the selected host, including remote hosts. It reads the file, shows its own
|
||||
loading state, and writes the file when the user edits it. The tab's bar shows the panel
|
||||
switcher, the overflow menu, and a dot while your panel reports unsaved changes.
|
||||
loading state, and writes the file when the user edits it. The tab shows a dot while your panel
|
||||
reports unsaved changes.
|
||||
|
||||
```tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
@@ -1906,13 +1906,13 @@ export default function contribute(client: PluginClientContext) {
|
||||
`addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function.
|
||||
Paseo also removes the registration when the client entry is unloaded.
|
||||
|
||||
| Field | Required | Meaning |
|
||||
| ----------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. |
|
||||
| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. |
|
||||
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
|
||||
| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. |
|
||||
| `Component` | Yes | Component receiving `PluginFilePanelProps`. |
|
||||
| Field | Required | Meaning |
|
||||
| ----------- | -------- | ------------------------------------------------------------------------ |
|
||||
| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. |
|
||||
| `title` | Yes | Non-empty label in the picker, the Open with menu, and Settings > Files. |
|
||||
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
|
||||
| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. |
|
||||
| `Component` | Yes | Component receiving `PluginFilePanelProps`. |
|
||||
|
||||
Extensions have no leading dot and match without case sensitivity. Filenames match the exact
|
||||
basename, including case. Selectors contain no paths, globs, or wildcards. Use `extensions: []`
|
||||
@@ -2002,8 +2002,8 @@ A panel that holds unsaved edits reads with `readText()` once, then handles `wat
|
||||
5. On `PluginFileConflictError`, show the same conflict.
|
||||
6. On `{ type: "deleted" }` or `PluginFileDeletedError`, tell the user the file is gone.
|
||||
|
||||
Write pending edits before the component unmounts: switching panels or closing the tab unmounts
|
||||
it. Paseo asks before closing a tab whose panel reports unsaved changes. The
|
||||
Write pending edits before the component unmounts: opening the tab with another panel or closing
|
||||
the tab unmounts it. Paseo asks before closing a tab whose panel reports unsaved changes. The
|
||||
[Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)
|
||||
example implements these steps.
|
||||
|
||||
@@ -2011,28 +2011,30 @@ example implements these steps.
|
||||
|
||||
A plugin panel is a candidate for every file its selector matches, text or binary. The built-in
|
||||
panels require the host's file kind: Markdown, HTML, and **Source** open text files; Image opens
|
||||
images. Candidates are ordered: the built-in Markdown, HTML, and Image panels, then plugin panels
|
||||
in host catalog order, then **Source**, which every text file has. Source opens a file by default
|
||||
only when nothing else claims it.
|
||||
images. The Markdown and HTML panels each have a Preview and a Source view of the file, switched
|
||||
in their toolbar. **Source**, Paseo's editor, is the built-in panel for text files that no other
|
||||
built-in panel claims; it is never offered beside another built-in panel. Candidates are ordered:
|
||||
the built-in panel that claims the file, then plugin panels in host catalog order, then Source
|
||||
when no other built-in claims the file.
|
||||
|
||||
A remembered association selects its panel when that panel is available on the current host.
|
||||
Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no
|
||||
association applies, Paseo asks once per tab open, over the panel it already opened. Built-in
|
||||
panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With
|
||||
a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown —
|
||||
markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with
|
||||
Source, so its first open asks; a plugin that alone claims a binary file opens without asking.
|
||||
association applies, Paseo asks once per tab open, over the panel it already opened. A file with
|
||||
one candidate never asks. With a Markdown plugin installed, the picker offers **Markdown — Paseo**
|
||||
and **Rich Markdown — markdown-panel**. A plugin that claims a text extension no built-in panel
|
||||
claims competes with Source, so its first open asks; a plugin that alone claims a binary file
|
||||
opens without asking.
|
||||
|
||||
**Don't ask me again** remembers the choice on this device for the extension. When a candidate
|
||||
matches an exact filename, the choice applies to that basename instead. An unavailable association
|
||||
is ignored on that host and retained for hosts where its panel is installed.
|
||||
|
||||
Users switch the current tab with the bar's panel switcher, which shows up to three panels side by
|
||||
side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice
|
||||
wins over the remembered association for this tab until it is retargeted. **Always use**, in the
|
||||
bar's overflow menu and in Open with, remembers the shown panel. Opening a file at a line, from a
|
||||
link or a search result, switches the tab to Source. Files with no candidate show **Binary preview
|
||||
unavailable**; binary files have no Source panel.
|
||||
Users choose a panel three ways: the picker on first open, **Open with…** in the tab menu, and
|
||||
**Settings > Files**. Open with switches the current tab and wins over the remembered association
|
||||
for this tab until it is retargeted; its **Always use** remembers the shown panel. Opening a file
|
||||
at a line, from a link or a search result, switches the tab to its built-in panel, which shows the
|
||||
line in its Source view. Files with no candidate show **Binary preview unavailable**; binary files
|
||||
have no Source panel.
|
||||
|
||||
### DocumentFrame
|
||||
|
||||
|
||||
@@ -1637,7 +1637,7 @@ Agent 快照字段:
|
||||
|
||||
## 文件面板 {#file-panels}
|
||||
|
||||
注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。标签页栏会显示面板切换器、溢出菜单,以及在面板报告有未保存更改时显示一个圆点。
|
||||
注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。面板报告有未保存更改时,标签页会显示一个圆点。
|
||||
|
||||
```tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
@@ -1659,7 +1659,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
| 字段 | 必填 | 含义 |
|
||||
| ----------- | ---- | -------------------------------------------------------------------- |
|
||||
| `id` | 是 | 插件内唯一的 ID,匹配 `^[a-z][a-z0-9-]*$`。 |
|
||||
| `title` | 是 | 显示在面板切换器、选择器和 **打开方式…** 菜单中的非空标签。 |
|
||||
| `title` | 是 | 显示在选择器、**打开方式…** 菜单和 **设置 > 文件** 中的非空标签。 |
|
||||
| `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 |
|
||||
| `files` | 是 | `{ extensions: readonly string[]; filenames?: readonly string[] }`。 |
|
||||
| `Component` | 是 | 接收 `PluginFilePanelProps` 的组件。 |
|
||||
@@ -1732,17 +1732,17 @@ hook 的重新读取会采用磁盘上的版本,因此下一次写入会覆盖
|
||||
5. 遇到 `PluginFileConflictError` 时,显示同样的冲突。
|
||||
6. 收到 `{ type: "deleted" }` 或遇到 `PluginFileDeletedError` 时,告诉用户文件已不存在。
|
||||
|
||||
请在组件卸载之前写入待保存的编辑:切换面板或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。
|
||||
请在组件卸载之前写入待保存的编辑:用其他面板打开标签页或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。
|
||||
|
||||
### 选择与打开方式 {#selection-and-open-with}
|
||||
|
||||
插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。候选面板的顺序是:内置的 Markdown、HTML 和图片面板,然后按主机目录顺序排列的插件面板,最后是每个文本文件都有的 **源代码**。只有没有其他面板认领文件时,源代码才会默认打开它。
|
||||
插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。Markdown 和 HTML 面板各自有文件的预览视图和源代码视图,在面板的工具栏中切换。**源代码** 是 Paseo 的编辑器,是没有其他内置面板认领的文本文件的内置面板;它从不与其他内置面板一起提供。候选面板的顺序是:认领该文件的内置面板,然后按主机目录顺序排列的插件面板,最后是没有其他内置面板认领该文件时的源代码。
|
||||
|
||||
记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有内置面板时从不询问:Markdown 文件用 Markdown 打开,并在切换器中提供源代码。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo**、**Rich Markdown — markdown-panel** 和 **源代码 — Paseo**。认领文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。
|
||||
记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有一个候选面板的文件从不询问。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo** 和 **Rich Markdown — markdown-panel**。认领某个没有内置面板认领的文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。
|
||||
|
||||
**不再询问** 会在此设备上为该扩展名记住这个选择。当某个候选面板匹配了确切的文件名时,该选择改为应用于那个 basename。不可用的关联在该主机上会被忽略,并为安装了其面板的主机保留。
|
||||
|
||||
用户可以用栏中的面板切换器切换当前标签页:它并排显示最多三个面板(手机上为两个),更多的面板放在菜单中;也可以用标签页菜单中的 **打开方式…**。对于这个标签页,该选择优先于记住的关联,直到标签页被重新定向。栏的溢出菜单和打开方式中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到源代码。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。
|
||||
用户有三种方式选择面板:第一次打开时的选择器、标签页菜单中的 **打开方式…**,以及 **设置 > 文件**。打开方式会切换当前标签页,并且对于这个标签页优先于记住的关联,直到标签页被重新定向;其中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到它的内置面板,并在源代码视图中显示该行。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。
|
||||
|
||||
### DocumentFrame {#documentframe}
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ test("PR routing declares stable behavior ownership", () => {
|
||||
desktop: [
|
||||
"packages/desktop/**",
|
||||
"packages/app/src/desktop/**",
|
||||
"packages/app/src/**/*.electron.{ts,tsx}",
|
||||
"packages/server/src/server/browser-tools/**",
|
||||
"packages/app/e2e/support/**",
|
||||
"packages/app/*config.{cjs,js,ts}",
|
||||
@@ -251,13 +252,11 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
|
||||
const filters = loadFilters(filtersPath);
|
||||
const browserSpecs = filesUnder("packages/app/e2e", (path) => path.endsWith(".spec.ts"));
|
||||
const desktopSpecs = filesUnder("packages/desktop/e2e", (path) => path.endsWith(".spec.ts"));
|
||||
const electronModules = filesUnder("packages/app/src", (path) => /\.electron\.tsx?$/.test(path));
|
||||
|
||||
assert.ok(browserSpecs.length > 0);
|
||||
assert.ok(desktopSpecs.length > 0);
|
||||
assert.ok(browserSpecs.every((path) => path.startsWith("packages/app/e2e/browser/")));
|
||||
assert.ok(desktopSpecs.every((path) => path.startsWith("packages/desktop/e2e/")));
|
||||
assert.ok(electronModules.every((path) => path.startsWith("packages/app/src/desktop/")));
|
||||
|
||||
const desktopPackage = JSON.parse(readFileSync(desktopPackagePath, "utf8"));
|
||||
assert.match(desktopPackage.scripts.test, /--exclude ["']e2e\/\*\*["']/);
|
||||
@@ -277,6 +276,7 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
|
||||
assert.deepEqual(filters.desktop, [
|
||||
"packages/desktop/**",
|
||||
"packages/app/src/desktop/**",
|
||||
"packages/app/src/**/*.electron.{ts,tsx}",
|
||||
"packages/server/src/server/browser-tools/**",
|
||||
"packages/app/e2e/support/**",
|
||||
"packages/app/*config.{cjs,js,ts}",
|
||||
|
||||
Reference in New Issue
Block a user