mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): Add workspace file browser and previews (#13878)
* feat(web-shell): Add workspace file browser and previews * fix(web-shell): Restore source preview CI fixtures and notices * fix(web-shell): Address file pane review blockers * fix(web-shell): Resolve review regressions and strengthen file checks --------- Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
afe4dd7113
commit
df5470e430
38 files changed
+8559
-152
No files matched your search
@@ -9,6 +9,7 @@
|
|||||||
*.bash eol=lf
|
*.bash eol=lf
|
||||||
Makefile eol=lf
|
Makefile eol=lf
|
||||||
packages/vscode-ide-companion/NOTICES.txt linguist-generated=true
|
packages/vscode-ide-companion/NOTICES.txt linguist-generated=true
|
||||||
|
packages/web-shell/NOTICES.txt linguist-generated=true whitespace=-blank-at-eof
|
||||||
|
|
||||||
# Windows cmd.exe expects batch installers to be checked out with CRLF.
|
# Windows cmd.exe expects batch installers to be checked out with CRLF.
|
||||||
scripts/installation/install-qwen-standalone.bat text eol=crlf
|
scripts/installation/install-qwen-standalone.bat text eol=crlf
|
||||||
|
|||||||
@@ -1257,18 +1257,21 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Settings schema is up-to-date"
|
echo "Settings schema is up-to-date"
|
||||||
|
|
||||||
- name: 'Generate VS Code companion notices'
|
- name: 'Generate Web Shell and VS Code companion notices'
|
||||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
||||||
run: 'npm run generate:notices --workspace=qwen-code-vscode-ide-companion'
|
|
||||||
|
|
||||||
- name: 'Check VS Code companion notices are up-to-date'
|
|
||||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||||
run: |-
|
run: |-
|
||||||
if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt) ]]; then
|
npm run generate:notices --workspace=@qwen-code/web-shell
|
||||||
|
npm run generate:notices --workspace=qwen-code-vscode-ide-companion
|
||||||
|
|
||||||
|
- name: 'Check Web Shell and VS Code companion notices are up-to-date'
|
||||||
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||||
|
run: |-
|
||||||
|
if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt) ]]; then
|
||||||
echo "Error: NOTICES.txt is out of date."
|
echo "Error: NOTICES.txt is out of date."
|
||||||
echo "Please run: npm run generate:notices --workspace=qwen-code-vscode-ide-companion"
|
echo "Please run: npm run generate:notices --workspace=qwen-code-vscode-ide-companion"
|
||||||
|
echo "And: npm run generate:notices --workspace=@qwen-code/web-shell"
|
||||||
echo "Then commit the updated file."
|
echo "Then commit the updated file."
|
||||||
git diff --stat packages/vscode-ide-companion/NOTICES.txt
|
git diff --stat packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "NOTICES.txt is up-to-date"
|
echo "NOTICES.txt is up-to-date"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Web Shell file browser
|
||||||
|
|
||||||
|
[English](web-shell-file-browser.md) | [简体中文](web-shell-file-browser.zh-CN.md)
|
||||||
|
|
||||||
|
## Problem and current state
|
||||||
|
|
||||||
|
The right extension area offers file changes but cannot browse workspace files.
|
||||||
|
The requested interface adds a Files entry beside the existing actions, with the
|
||||||
|
selected file on the left and a persistent directory tree on the right.
|
||||||
|
|
||||||
|
## Proposed solution
|
||||||
|
|
||||||
|
Add an opt-in `files` right-panel item and a workspace-owned `files` tab. Enable
|
||||||
|
it in the standalone page. The empty-state action and add menu open or activate
|
||||||
|
one Files tab per workspace. Reuse existing panel sizing, closing, fullscreen,
|
||||||
|
and persistence behavior. Before selecting a file, show only the full-width tree.
|
||||||
|
Selecting a file opens the two columns with about 70% for content and 30% for
|
||||||
|
the tree, without rebuilding the tree; subsequent
|
||||||
|
file selections keep it visible.
|
||||||
|
|
||||||
|
Use `@pierre/trees` for the read-only tree and `@pierre/diffs` File for read-only
|
||||||
|
source rendering. Retain existing Markdown, HTML and image previews. Markdown
|
||||||
|
and HTML default to rendered preview with a source toggle. Other files use the
|
||||||
|
existing supported-file/error behavior. No Git state or editing is enabled.
|
||||||
|
|
||||||
|
Load only the root and expanded directories through the existing daemon API.
|
||||||
|
Keep loaded paths in the Pierre model; directory expansion loads direct children.
|
||||||
|
Search covers loaded paths only and never fetches search-expanded directories.
|
||||||
|
Keep the raw filter input unchanged; show an explicit no-match state within loaded
|
||||||
|
paths. Selection or Escape closes filtering. A nonempty filter consumes Escape
|
||||||
|
before fullscreen or drawer dismissal; IME composition keeps its native Escape.
|
||||||
|
Refresh clears directory cache and reloads
|
||||||
|
the root and selected file, collapsing branches. Hide the filter-scope explanation
|
||||||
|
and per-directory loading log; show retryable errors, empty directories and
|
||||||
|
truncated-list warnings only when needed. Never recurse into symlinks.
|
||||||
|
Files expose a right-click menu for relative/absolute path copying, downloading,
|
||||||
|
previewing supported Markdown/HTML/images in a separate panel tab, and referencing
|
||||||
|
the file in the current composer. Reference uses the same tag construction and
|
||||||
|
inline insertion as the built-in `@` file picker, only for the current workspace.
|
||||||
|
Reference closes a floating drawer before returning focus to the composer;
|
||||||
|
the desktop dock stays open.
|
||||||
|
Downloads reuse the existing bounded, owner-checked byte reader and cancellation.
|
||||||
|
Both columns scroll independently. The tree scrolls horizontally as a whole,
|
||||||
|
keeping indentation and complete names at deep levels. Use Pierre’s shadow-root
|
||||||
|
CSS extension to size the virtual list by its content and prevent label shrinking. A keyboard-accessible draggable separator
|
||||||
|
uses the shared shadcn resizable primitive to adjust their widths, preserving the
|
||||||
|
layout across tab switches. Leave 12px below the tree. The tree can be hidden to read content in a
|
||||||
|
narrow panel without closing the Files tab.
|
||||||
|
|
||||||
|
## Ownership and lifecycle
|
||||||
|
|
||||||
|
The Files tab captures workspace cwd and ID. Extend the existing artifact owner
|
||||||
|
guard with directory reads, checking authority before and after requests. Use
|
||||||
|
workspace-qualified SDK directory reads over REST (including ACP clients) whenever workspace IDs are advertised;
|
||||||
|
retain the primary route only for legacy daemons. Unavailable, ambiguous,
|
||||||
|
untrusted or changed owners fail closed. Workspace changes discard late results.
|
||||||
|
Files panes remain mounted while inactive, preserving selection, expansion,
|
||||||
|
source/preview mode, filtering, column sizes and scroll position across tab switches. Each pane
|
||||||
|
checks its own workspace owner even while hidden; closing the tab or losing its
|
||||||
|
owner discards that state. Only tab workspace identity persists across reloads. A stale persisted owner stays
|
||||||
|
blocked, but does not hide the action for reopening the current trusted owner. File content loading retains existing size limits and cancellation.
|
||||||
|
|
||||||
|
## Scope and decisions
|
||||||
|
|
||||||
|
No filesystem mutations, code editing, Git operations, watchers or whole-workspace
|
||||||
|
search index. Opening files does not add them to model context; the explicit
|
||||||
|
Reference action inserts a file tag without sending a prompt. Keep existing
|
||||||
|
review, attachment and file tabs unchanged. Pierre uses Shadow DOM; style its
|
||||||
|
public variables with Web Shell semantic colors. Use its vanilla entry points to
|
||||||
|
preserve the package's existing React 18/19 peer range. Pierre is a pinned build-time
|
||||||
|
dependency: its vanilla implementation is bundled, so published consumers do not
|
||||||
|
install its React peers (which require 18.3.1 or later).
|
||||||
|
The explicit `bundledDevDependencies` list seeds license generation without
|
||||||
|
including unrelated development tools. Bundled dependency licenses and NOTICE
|
||||||
|
credits ship in the npm package and the VS Code companion notice file.
|
||||||
|
Tree and source loading have local error boundaries. Failed imports offer a page
|
||||||
|
reload only in the standalone shell when credentials survive it (or no token is
|
||||||
|
needed). Embedded hosts and memory-only credentials instead offer Close Files,
|
||||||
|
without reloading the host or presenting a cached import as a local retry.
|
||||||
|
Pierre render errors propagate to the local boundary after resource cleanup.
|
||||||
|
The constructor uses the resolved shell theme; theme changes update it in place. Floating drawers traverse shadow-root tab
|
||||||
|
stops before Radix's light-DOM focus loop can skip the tree.
|
||||||
|
Source rendering is virtualized; theme changes update the renderer in place. The library
|
||||||
|
build bundles Pierre's compatible Shiki 3/4 dependency in the lazy preview chunk,
|
||||||
|
while retaining the existing Shiki 1 dependency for other renderers; files over 10,000 lines or 1,048,576 characters use plain
|
||||||
|
text to bound syntax-highlighting work. Files source previews refuse files above
|
||||||
|
4 MiB before reading their content, and apply the same cap to chunked fallback
|
||||||
|
reads. Users can download these files from the file menu. Files translations are
|
||||||
|
excluded from the transcript-only bundle. The actual shadow tree and file menu
|
||||||
|
have accessible names.
|
||||||
|
|
||||||
|
The file menu also offers Preview for `.xlsx` files, reusing the shared spreadsheet preview in a separate workspace-scoped file tab. Opening the same file again reuses that tab; returning to Files retains the tree and inline selection. Other Excel formats retain the existing download-only behavior.
|
||||||
|
|
||||||
|
## File changes assessment
|
||||||
|
|
||||||
|
Pierre FileDiff can render the existing before/after text, unified/split views,
|
||||||
|
line numbers and inline changes. It can replace the visual renderer later. The
|
||||||
|
input may contain full snapshots, unified patches or partial edit fragments.
|
||||||
|
Partial fragments must not be presented with full-file line numbers, and repeated
|
||||||
|
edits to one file must retain their recorded segments. The current file-changes
|
||||||
|
feature additionally owns tool-call grouping, aggregated
|
||||||
|
line statistics, recorded-turn versus latest-workspace semantics, file opening,
|
||||||
|
downloads and missing/binary file handling. Keep those responsibilities intact.
|
||||||
|
Migrating the renderer requires separate validation of CodeMirror merge behavior,
|
||||||
|
large/truncated changes, cancellation, themes and wrapped lines. This change only
|
||||||
|
records the assessment; it does not migrate review rendering or connect Git.
|
||||||
|
|
||||||
|
## Affected areas
|
||||||
|
|
||||||
|
- Artifact panel, customization, standalone entry, translations and README.
|
||||||
|
- App tab opening, persistence and workspace ownership wiring.
|
||||||
|
- A workspace file browser, Pierre source renderer and focused tests.
|
||||||
|
- Guarded artifact workspace directory reads and authority tests.
|
||||||
|
- Browser E2E tests using the existing mock daemon, explicitly labeled as mock.
|
||||||
|
|
||||||
|
## Validation and acceptance criteria
|
||||||
|
|
||||||
|
1. Files appears beside File changes; initially only the tree is visible. Selecting
|
||||||
|
a file opens content on the left and the tree on the right.
|
||||||
|
2. Directory expansion loads direct children and reuses cached results. Deep
|
||||||
|
paths and long names remain readable through horizontal tree scrolling.
|
||||||
|
3. Selecting files changes content while keeping the tree visible.
|
||||||
|
4. Markdown renders as a document and switches to highlighted read-only source.
|
||||||
|
5. Filtering preserves input, shows no matches explicitly and does not load
|
||||||
|
directories. Refresh, empty/error and incomplete-list states are visible.
|
||||||
|
6. Right-click copy paths, byte download, preview tabs and `@` references work.
|
||||||
|
7. Untrusted/changed workspace owners and late responses cannot leak content.
|
||||||
|
8. Tab switches retain file-browser state; closing/reopening, page reload tab
|
||||||
|
restoration and narrow layouts remain usable.
|
||||||
|
9. Existing review and attachment behaviors remain covered.
|
||||||
|
10. File browsing and filtering enter the PR browser smoke gate. Focused ownership,
|
||||||
|
persistence and transport tests, build, typecheck and inspected browser screenshots provide evidence.
|
||||||
|
|
||||||
|
## Open questions and risks
|
||||||
|
|
||||||
|
Trees is Beta; pin the version and validate actual expansion/model-reset behavior.
|
||||||
|
Git badges, review migration, live watchers and global search are future work.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Web Shell 文件浏览器
|
||||||
|
|
||||||
|
[English](web-shell-file-browser.md) | [简体中文](web-shell-file-browser.zh-CN.md)
|
||||||
|
|
||||||
|
## 问题与现状
|
||||||
|
|
||||||
|
右侧拓展区提供文件更改,但无法浏览工作区文件。此次在现有操作旁新增
|
||||||
|
「文件」入口,选中文件的内容在左侧,持续可见的目录树在右侧。
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
新增可选的 `files` 右侧面板配置项及具有工作区归属的 `files` 页签,独立页面
|
||||||
|
默认启用。空态入口和加号菜单打开或激活每个工作区唯一的文件页签。复用面板
|
||||||
|
尺寸、关闭、全屏和持久化行为。尚未选择文件时仅显示占满宽度的目录树,点击
|
||||||
|
文件后显示两列,初始约 70% 用于内容、30% 用于目录树,且不重建目录树;后续切换文件时目录树持续可见。
|
||||||
|
|
||||||
|
采用 `@pierre/trees` 展示只读目录树,采用 `@pierre/diffs` 的 File 展示只读
|
||||||
|
源码。保留现有 Markdown、HTML 和图片预览。Markdown 和 HTML 默认渲染预览,
|
||||||
|
可切换源码。其他文件沿用现有文件支持及错误行为。不启用 Git 状态或编辑。
|
||||||
|
|
||||||
|
通过现有 daemon API 仅加载根目录和已展开目录。Pierre 模型保存已加载路径,
|
||||||
|
展开目录时加载直接子节点。搜索仅覆盖已加载路径,不读取因搜索而展开的目录。筛选输入保留原文,零匹配时明确
|
||||||
|
展示「已加载路径内无匹配」。选择文件或按 Escape 结束筛选。筛选非空时优先消费 Escape,
|
||||||
|
不会退出全屏或关闭抽屉;输入法组合期间保留原生 Escape 行为。刷新清空目录缓存,重新加载
|
||||||
|
根目录和当前文件,并收起分支。不展示筛选范围说明和逐目录加载日志,仅在需要时
|
||||||
|
展示可重试错误、空目录和列表截断提示。不递归展开符号链接。两列独立滚动。目录树支持整体横向滚动,保留深层缩进和完整名称;
|
||||||
|
通过 Pierre 的 Shadow DOM CSS 扩展按内容确定虚拟列表宽度,避免名称被压缩。
|
||||||
|
中间使用共享 shadcn 可调整尺寸组件提供
|
||||||
|
支持键盘的拖动分隔线,切换页签保留宽度。文件树底部留出 12px。窄面板可隐藏目录树以阅读
|
||||||
|
内容,无需关闭文件页签。文件右键菜单提供相对与绝对路径复制、下载、在独立
|
||||||
|
页签预览 Markdown/HTML/图片,以及引用到当前输入框。引用复用内置 `@` 文件
|
||||||
|
选择器的标签构造与行内插入,仅面向当前工作区。引用时关闭浮动抽屉,再将焦点交回输入框;
|
||||||
|
桌面停靠面板保持展开。下载复用现有大小受限、具有
|
||||||
|
归属检查和取消支持的字节读取流程。
|
||||||
|
|
||||||
|
## 归属与生命周期
|
||||||
|
|
||||||
|
文件页签保存工作区 cwd 和 ID。扩展现有产物工作区权限守卫,目录请求前后均
|
||||||
|
校验归属。daemon 公布工作区 ID 时通过具有工作区限定的 SDK REST 接口读取目录(包括 ACP 客户端);
|
||||||
|
仅旧 daemon 保留主工作区接口。不可用、歧义、不可信或已变更的归属均拒绝访问。
|
||||||
|
切换工作区后丢弃迟到结果。文件页签未激活时仍保持挂载,切换页签保留选中、
|
||||||
|
展开、源码/预览模式、筛选、列宽和滚动位置。隐藏页签仍独立校验工作区归属;关闭页签
|
||||||
|
或归属失效时丢弃状态。页面刷新仅恢复页签工作区身份。旧归属页签继续拒绝访问,但不遮蔽重新打开当前可信
|
||||||
|
归属的入口。
|
||||||
|
文件内容加载保留现有大小限制和取消行为。
|
||||||
|
|
||||||
|
## 范围与决策
|
||||||
|
|
||||||
|
不实现文件系统修改、代码编辑、Git 操作、监听器或全工作区搜索索引。打开文件
|
||||||
|
不会自动加入模型上下文;显式「引用」仅插入文件标签,不发送请求。保留现有审查、附件和文件页签行为。Pierre 使用
|
||||||
|
Shadow DOM,通过公开变量适配 Web Shell 语义颜色。使用原生入口以保留包现有
|
||||||
|
React 18/19 peer 范围。Pierre 固定版本并作为构建期依赖,原生实现完整打包,
|
||||||
|
发布包的使用方无需安装其要求 18.3.1 起的 React peer。
|
||||||
|
显式 `bundledDevDependencies` 清单作为许可证生成种子,不包含无关开发工具。
|
||||||
|
打包依赖的许可证与 NOTICE 署名同时进入 npm 发布包和 VS Code 扩展声明文件。
|
||||||
|
文件树与源码加载各有局部错误边界。导入失败时,仅独立页面在凭据可恢复或不需要 token 时提供页面重载;
|
||||||
|
嵌入宿主和仅保存在内存中的凭据改为提供「关闭文件」,不重载宿主,也不把缓存的导入失败伪装成本地重试。
|
||||||
|
Pierre 渲染错误在释放资源后进入局部边界;构造时即使用当前 Web Shell 主题,后续原地更新。浮动抽屉遍历 Shadow DOM 中的 Tab 焦点,
|
||||||
|
防止 Radix 仅遍历普通 DOM 的焦点循环跳过文件树。
|
||||||
|
源码采用虚拟滚动;切换主题原地更新渲染器。库构建将 Pierre 兼容的 Shiki 3/4
|
||||||
|
依赖打包进延迟预览模块,其他渲染器保留现有 Shiki 1 依赖;超过 10,000 行或 1,048,576 字符时使用纯文本,以限制语法高亮开销。文件树的源码预览在读取内容前拒绝超过 4 MiB 的文件,分块回退读取也使用同一上限;
|
||||||
|
用户仍可通过文件菜单下载。Files 文案不进入只读 transcript 包;实际 Shadow DOM 文件树和文件菜单均有可访问名称。
|
||||||
|
|
||||||
|
文件右键菜单也为 `.xlsx` 提供「预览」,复用共享表格预览,在独立且绑定工作区的文件页签中打开。同一文件重复打开复用页签,返回「文件」保留目录树和内联选中。其他 Excel 格式沿用现有的仅下载行为。
|
||||||
|
|
||||||
|
## 文件更改承载评估
|
||||||
|
|
||||||
|
Pierre FileDiff 可展示现有修改前后文本、合并或双栏视图、行号和行内变更,后续
|
||||||
|
可替换显示层。输入可能是完整快照、统一补丁或局部编辑片段。局部片段不能展示为
|
||||||
|
完整文件的真实行号,同一文件的多次编辑也必须保留记录的分段。目前文件更改还
|
||||||
|
负责工具调用分组、汇总行数统计、历史轮次与最新
|
||||||
|
工作区语义、打开文件、下载及缺失或二进制文件处理,这些职责应继续保留。
|
||||||
|
迁移渲染器需要单独验证 CodeMirror merge 行为、大型及截断变更、取消、主题和
|
||||||
|
折行。本次仅记录评估,不迁移审查渲染,也不接入 Git。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- 产物面板、配置类型、独立入口、翻译和 README。
|
||||||
|
- App 页签打开、持久化及工作区归属连接。
|
||||||
|
- 工作区文件浏览器、Pierre 源码渲染器及针对性测试。
|
||||||
|
- 具有权限守卫的产物工作区目录读取与归属测试。
|
||||||
|
- 使用现有模拟 daemon 的浏览器 E2E 测试,明确标注模拟环境。
|
||||||
|
|
||||||
|
## 验证与验收标准
|
||||||
|
|
||||||
|
1. 「文件」与「文件更改」并列,初始仅显示目录树;选择文件后显示左侧内容、右侧目录树。
|
||||||
|
2. 展开目录读取直接子节点并复用缓存;深层路径和长名称可通过目录树横向滚动完整阅读。
|
||||||
|
3. 选择不同文件切换内容,目录树持续可见。
|
||||||
|
4. Markdown 显示文档,可切换高亮只读源码。
|
||||||
|
5. 筛选保留输入、明确展示零匹配且不加载目录;刷新、空态、错误和不完整列表提示可见。
|
||||||
|
6. 右键路径复制、字节下载、独立预览和 `@` 引用均可用。
|
||||||
|
7. 不可信或已变更的工作区归属及迟到响应不能泄露内容。
|
||||||
|
8. 切换页签保留文件浏览状态;面板关闭重开、页面刷新恢复页签和窄布局保持可用。
|
||||||
|
9. 现有审查与附件行为有回归覆盖。
|
||||||
|
10. 文件浏览与筛选进入 PR 浏览器 smoke 门禁;归属、持久化与传输针对性测试、构建、
|
||||||
|
类型检查及目视检查浏览器截图提供验证证据。
|
||||||
|
|
||||||
|
## 未决问题与风险
|
||||||
|
|
||||||
|
Trees 仍处于 Beta,固定版本并验证真实的展开及模型重置行为。
|
||||||
|
Git 标记、审查迁移、实时监听和全局搜索留待后续。
|
||||||
@@ -8431,9 +8431,11 @@ export class WorkspaceDaemonClient {
|
|||||||
|
|
||||||
dirList(dirPath: string): Promise<unknown> {
|
dirList(dirPath: string): Promise<unknown> {
|
||||||
const query = new URLSearchParams({ path: dirPath });
|
const query = new URLSearchParams({ path: dirPath });
|
||||||
return this.get(
|
return this.client.workspaceJsonRequest(
|
||||||
|
this.workspaceSelector,
|
||||||
`/list?${query.toString()}`,
|
`/list?${query.toString()}`,
|
||||||
'GET /workspaces/:workspace/list',
|
'GET /workspaces/:workspace/list',
|
||||||
|
{ mode: 'rest' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10740,6 +10740,78 @@ describe('DaemonClient', () => {
|
|||||||
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
|
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['id', 'cwd'] as const)(
|
||||||
|
'lists the selected workspace over REST with an ACP transport (%s)',
|
||||||
|
async (selector) => {
|
||||||
|
const body = {
|
||||||
|
kind: 'list',
|
||||||
|
path: 'src',
|
||||||
|
entries: [],
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
const { fetch, calls } = recordingFetch(() => jsonResponse(200, body));
|
||||||
|
const transportFetch = vi.fn(async () => {
|
||||||
|
throw new Error('must use workspace REST');
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({
|
||||||
|
baseUrl: 'http://daemon',
|
||||||
|
token: 'list-token',
|
||||||
|
fetch,
|
||||||
|
transport: {
|
||||||
|
type: 'acp-http',
|
||||||
|
supportsReplay: true,
|
||||||
|
connected: true,
|
||||||
|
fetch: transportFetch,
|
||||||
|
async *subscribeEvents() {},
|
||||||
|
dispose() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const workspace =
|
||||||
|
selector === 'id'
|
||||||
|
? client.workspaceById('workspace/id')
|
||||||
|
: client.workspaceByCwd('/tmp/work space');
|
||||||
|
await expect(workspace.dirList('src')).resolves.toEqual(body);
|
||||||
|
expect(transportFetch).not.toHaveBeenCalled();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `http://daemon/workspaces/${selector === 'id' ? 'workspace%2Fid' : '%2Ftmp%2Fwork%20space'}/list?path=src`,
|
||||||
|
headers: { authorization: 'Bearer list-token' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not fall back to the primary directory when workspace listing is rejected', async () => {
|
||||||
|
const { fetch, calls } = recordingFetch(() =>
|
||||||
|
jsonResponse(403, {
|
||||||
|
error: 'Workspace is not trusted.',
|
||||||
|
code: 'untrusted_workspace',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const transportFetch = vi.fn(async () => {
|
||||||
|
throw new Error('must use workspace REST');
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({
|
||||||
|
baseUrl: 'http://daemon',
|
||||||
|
fetch,
|
||||||
|
transport: {
|
||||||
|
type: 'acp-http',
|
||||||
|
supportsReplay: true,
|
||||||
|
connected: true,
|
||||||
|
fetch: transportFetch,
|
||||||
|
async *subscribeEvents() {},
|
||||||
|
dispose() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.workspaceById('removed-owner').dirList('.'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(transportFetch).not.toHaveBeenCalled();
|
||||||
|
expect(calls.map((call) => call.url)).toEqual([
|
||||||
|
'http://daemon/workspaces/removed-owner/list?path=.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('reads complete turn calls through encoded workspace REST without pagination', async () => {
|
it('reads complete turn calls through encoded workspace REST without pagination', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
v: 1,
|
v: 1,
|
||||||
|
|||||||
+1550
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,6 @@ const projectRoot = path.resolve(
|
|||||||
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'),
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'),
|
||||||
);
|
);
|
||||||
const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion');
|
const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion');
|
||||||
const noticeFilePath = path.join(packagePath, 'NOTICES.txt');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard MIT license text used when a package declares MIT but ships no
|
* Standard MIT license text used when a package declares MIT but ships no
|
||||||
@@ -415,7 +414,13 @@ export async function collectDependencies(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const depName of Object.keys(manifest.dependencies ?? {})) {
|
const bundled = dir.split(path.sep).includes('node_modules')
|
||||||
|
? []
|
||||||
|
: (manifest.bundledDevDependencies ?? []);
|
||||||
|
for (const depName of [
|
||||||
|
...Object.keys(manifest.dependencies ?? {}),
|
||||||
|
...bundled,
|
||||||
|
]) {
|
||||||
// Web Shell uses ExcelJS's prebuilt browser entry. These Node-only branches
|
// Web Shell uses ExcelJS's prebuilt browser entry. These Node-only branches
|
||||||
// are not in that bundle; its actual embedded packages are inventoried below.
|
// are not in that bundle; its actual embedded packages are inventoried below.
|
||||||
if (
|
if (
|
||||||
@@ -452,77 +457,84 @@ export async function getExceljsBrowserNotices(packageDir) {
|
|||||||
return inventory.packages;
|
return inventory.packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main({ packageDir = packagePath, bundledOnly = false } = {}) {
|
||||||
try {
|
const packageJsonPath = path.join(packageDir, 'package.json');
|
||||||
const packageJsonPath = path.join(packagePath, 'package.json');
|
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
|
||||||
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
|
const packageJson = JSON.parse(packageJsonContent);
|
||||||
const packageJson = JSON.parse(packageJsonContent);
|
|
||||||
|
|
||||||
const allDependencies = new Map();
|
const allDependencies = new Map();
|
||||||
const visitedDirs = new Set();
|
const visitedDirs = new Set();
|
||||||
const directDependencies = Object.keys(packageJson.dependencies);
|
const directDependencies = bundledOnly
|
||||||
|
? (packageJson.bundledDevDependencies ?? [])
|
||||||
|
: Object.keys(packageJson.dependencies);
|
||||||
|
|
||||||
// Sequential on purpose: the traversal order is the file's order, and it
|
if (bundledOnly) {
|
||||||
// must not depend on which filesystem read finishes first.
|
if (directDependencies.length === 0)
|
||||||
for (const depName of directDependencies) {
|
throw new Error('No bundled dependency seeds declared');
|
||||||
await collectDependencies(
|
for (const name of directDependencies) {
|
||||||
depName,
|
if (!(await resolveInstalled(name, packageDir)))
|
||||||
packagePath,
|
throw new Error(`Bundled dependency ${name} is not installed`);
|
||||||
allDependencies,
|
|
||||||
visitedDirs,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dependencyEntries = Array.from(allDependencies.values());
|
|
||||||
|
|
||||||
const exceljs = dependencyEntries.find(({ name }) => name === 'exceljs');
|
|
||||||
const browserLicenses = exceljs
|
|
||||||
? await getExceljsBrowserNotices(exceljs.dir)
|
|
||||||
: [];
|
|
||||||
const bundledLicenses = new Map(
|
|
||||||
browserLicenses.map((license) => [
|
|
||||||
`${license.name}@${license.version}`,
|
|
||||||
license,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const licensePromises = dependencyEntries.map(
|
|
||||||
({ name, version, dir }) =>
|
|
||||||
bundledLicenses.get(`${name}@${version}`) ??
|
|
||||||
getDependencyLicense(name, version, dir),
|
|
||||||
);
|
|
||||||
const dependencyLicenses = [
|
|
||||||
...(await Promise.all(licensePromises)),
|
|
||||||
...browserLicenses.filter(
|
|
||||||
({ name, version }) => !allDependencies.has(`${name}@${version}`),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let noticeText =
|
|
||||||
'This file contains third-party software notices and license terms.\n\n';
|
|
||||||
|
|
||||||
for (const dep of dependencyLicenses) {
|
|
||||||
noticeText +=
|
|
||||||
'============================================================\n';
|
|
||||||
noticeText += `${dep.name}@${dep.version}\n`;
|
|
||||||
noticeText += `(${dep.repository})\n\n`;
|
|
||||||
noticeText += `${dep.license}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize line endings to LF. Third-party license files may use CRLF,
|
|
||||||
// which would otherwise be embedded verbatim and produce spurious diffs
|
|
||||||
// (the file is declared `eol=lf` in .gitattributes).
|
|
||||||
noticeText = noticeText.replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
await fs.writeFile(noticeFilePath, noticeText);
|
|
||||||
console.log(`NOTICES.txt generated at ${noticeFilePath}`);
|
|
||||||
console.log(`Total dependencies: ${dependencyLicenses.length}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating NOTICES.txt:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sequential on purpose: the traversal order is the file's order, and it
|
||||||
|
// must not depend on which filesystem read finishes first.
|
||||||
|
for (const depName of directDependencies) {
|
||||||
|
await collectDependencies(
|
||||||
|
depName,
|
||||||
|
packageDir,
|
||||||
|
allDependencies,
|
||||||
|
visitedDirs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependencyEntries = Array.from(allDependencies.values());
|
||||||
|
|
||||||
|
const exceljs = dependencyEntries.find(({ name }) => name === 'exceljs');
|
||||||
|
const browserLicenses = exceljs
|
||||||
|
? await getExceljsBrowserNotices(exceljs.dir)
|
||||||
|
: [];
|
||||||
|
const bundledLicenses = new Map(
|
||||||
|
browserLicenses.map((license) => [
|
||||||
|
`${license.name}@${license.version}`,
|
||||||
|
license,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const licensePromises = dependencyEntries.map(
|
||||||
|
({ name, version, dir }) =>
|
||||||
|
bundledLicenses.get(`${name}@${version}`) ??
|
||||||
|
getDependencyLicense(name, version, dir),
|
||||||
|
);
|
||||||
|
const dependencyLicenses = [
|
||||||
|
...(await Promise.all(licensePromises)),
|
||||||
|
...browserLicenses.filter(
|
||||||
|
({ name, version }) => !allDependencies.has(`${name}@${version}`),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let noticeText =
|
||||||
|
'This file contains third-party software notices and license terms.\n\n';
|
||||||
|
|
||||||
|
for (const dep of dependencyLicenses) {
|
||||||
|
noticeText +=
|
||||||
|
'============================================================\n';
|
||||||
|
noticeText += `${dep.name}@${dep.version}\n`;
|
||||||
|
noticeText += `(${dep.repository})\n\n`;
|
||||||
|
noticeText += `${dep.license}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize line endings to LF. Third-party license files may use CRLF,
|
||||||
|
// which would otherwise be embedded verbatim and produce spurious diffs
|
||||||
|
// (the file is declared `eol=lf` in .gitattributes).
|
||||||
|
noticeText = noticeText.replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
const noticeFilePath = path.join(packageDir, 'NOTICES.txt');
|
||||||
|
await fs.writeFile(noticeFilePath, noticeText);
|
||||||
|
console.log(`NOTICES.txt generated at ${noticeFilePath}`);
|
||||||
|
console.log(`Total dependencies: ${dependencyLicenses.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runNoticeGeneration(env = process.env) {
|
export async function runNoticeGeneration(env = process.env, options) {
|
||||||
const skipGeneration = ['1', 'true'].includes(
|
const skipGeneration = ['1', 'true'].includes(
|
||||||
(env.QWEN_SKIP_NOTICE_GENERATION ?? '').toLowerCase(),
|
(env.QWEN_SKIP_NOTICE_GENERATION ?? '').toLowerCase(),
|
||||||
);
|
);
|
||||||
@@ -534,7 +546,7 @@ export async function runNoticeGeneration(env = process.env) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await main();
|
await main(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only run when executed directly (e.g. `npm run generate:notices`), not when
|
// Only run when executed directly (e.g. `npm run generate:notices`), not when
|
||||||
@@ -543,5 +555,12 @@ if (
|
|||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
) {
|
) {
|
||||||
runNoticeGeneration().catch(console.error);
|
const options =
|
||||||
|
process.argv[2] === '--bundled'
|
||||||
|
? { packageDir: path.resolve(process.argv[3]), bundledOnly: true }
|
||||||
|
: undefined;
|
||||||
|
runNoticeGeneration(process.env, options).catch((error) => {
|
||||||
|
console.error('Error generating NOTICES.txt:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
@@ -392,6 +394,95 @@ describe('collectDependencies', () => {
|
|||||||
|
|
||||||
expect([...dependencies.keys()]).toEqual(['[email protected]', '[email protected]']);
|
expect([...dependencies.keys()]).toEqual(['[email protected]', '[email protected]']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects missing bundled seeds without overwriting committed attribution', async () => {
|
||||||
|
await writePackage(root, { bundledDevDependencies: ['missing-package'] });
|
||||||
|
const output = path.join(root, 'NOTICES.txt');
|
||||||
|
await fs.writeFile(output, 'EXISTING ATTRIBUTION');
|
||||||
|
await expect(
|
||||||
|
runNoticeGeneration({}, { packageDir: root, bundledOnly: true }),
|
||||||
|
).rejects.toThrow('missing-package');
|
||||||
|
expect(await fs.readFile(output, 'utf8')).toBe('EXISTING ATTRIBUTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes declared bundled dev dependencies and their notices, excluding other dev tools', async () => {
|
||||||
|
const workspace = path.join(root, 'packages', 'web-shell');
|
||||||
|
await writePackage(workspace, {
|
||||||
|
name: '@qwen-code/web-shell',
|
||||||
|
version: '1.0.0',
|
||||||
|
bundledDevDependencies: ['bundled'],
|
||||||
|
dependencies: { ordinary: '1.0.0' },
|
||||||
|
devDependencies: { bundled: '1.0.0', unused: '1.0.0' },
|
||||||
|
});
|
||||||
|
await fs.mkdir(path.join(root, 'node_modules', '@qwen-code'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.symlink(
|
||||||
|
workspace,
|
||||||
|
path.join(root, 'node_modules', '@qwen-code', 'web-shell'),
|
||||||
|
'junction',
|
||||||
|
);
|
||||||
|
for (const name of ['ordinary', 'unused', 'extra'])
|
||||||
|
await writePackage(path.join(root, 'node_modules', name), {
|
||||||
|
name,
|
||||||
|
version: '1.0.0',
|
||||||
|
license: 'MIT',
|
||||||
|
});
|
||||||
|
const bundled = path.join(root, 'node_modules', 'bundled');
|
||||||
|
await writePackage(bundled, {
|
||||||
|
name: 'bundled',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: { leaf: '1.0.0' },
|
||||||
|
bundledDevDependencies: ['extra'],
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(bundled, 'LICENSE'), 'BUNDLED LICENSE');
|
||||||
|
await fs.writeFile(path.join(bundled, 'NOTICE.md'), 'DERIVED CODE CREDIT');
|
||||||
|
await writePackage(path.join(root, 'node_modules', 'leaf'), {
|
||||||
|
name: 'leaf',
|
||||||
|
version: '1.0.0',
|
||||||
|
license: 'MIT',
|
||||||
|
});
|
||||||
|
const dependencies = new Map();
|
||||||
|
await collectDependencies(
|
||||||
|
'@qwen-code/web-shell',
|
||||||
|
root,
|
||||||
|
dependencies,
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
expect([...dependencies.keys()]).toEqual([
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
]);
|
||||||
|
await runNoticeGeneration({}, { packageDir: workspace, bundledOnly: true });
|
||||||
|
const notice = await fs.readFile(
|
||||||
|
path.join(workspace, 'NOTICES.txt'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
expect(notice).toContain('[email protected]');
|
||||||
|
expect(notice).toContain('BUNDLED LICENSE');
|
||||||
|
expect(notice).toContain('DERIVED CODE CREDIT');
|
||||||
|
expect(notice).toContain('[email protected]');
|
||||||
|
expect(notice).not.toContain('unused');
|
||||||
|
expect(notice).not.toContain('ordinary');
|
||||||
|
await fs.unlink(path.join(workspace, 'NOTICES.txt'));
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
fileURLToPath(new URL('./generate-notices.js', import.meta.url)),
|
||||||
|
'--bundled',
|
||||||
|
workspace,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, QWEN_SKIP_NOTICE_GENERATION: '' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(await fs.readFile(path.join(workspace, 'NOTICES.txt'), 'utf8')).toBe(
|
||||||
|
notice,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('normalizeRepositoryUrl', () => {
|
describe('normalizeRepositoryUrl', () => {
|
||||||
|
|||||||
Generated
+2435
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,28 @@ Shell 执行。卡片区分执行、捕获与交付状态,面板按固定版
|
|||||||
安装。关闭 `general.enableAutoUpdate`、旧服务、桌面 shell、不支持的安装及嵌入式
|
安装。关闭 `general.enableAutoUpdate`、旧服务、桌面 shell、不支持的安装及嵌入式
|
||||||
服务不显示按钮。临时配对连接也不提供更新入口。使用自定义 `sidebar.footer.items` 时,通过 `update` 项控制此功能。
|
服务不显示按钮。临时配对连接也不提供更新入口。使用自定义 `sidebar.footer.items` 时,通过 `update` 项控制此功能。
|
||||||
|
|
||||||
|
## 工作区文件浏览
|
||||||
|
|
||||||
|
独立页面右侧拓展区的「文件」入口打开只读浏览器:未选择文件时仅展示
|
||||||
|
Pierre 目录树,点击文件后左侧显示内容、右侧保留目录树。Markdown、HTML 和图片沿用现有预览,文本源码由 Pierre File
|
||||||
|
高亮展示;Markdown 和 HTML 可以切换预览与源码。目录按需加载,筛选仅覆盖
|
||||||
|
已加载路径,不因筛选而读取目录;输入保留原文,零匹配时显示明确提示。刷新重新加载根目录和当前文件,并收起目录。窄面板可隐藏文件树。
|
||||||
|
中间分隔线可拖动调整两列宽度,也支持键盘方向键;文件树底部保留留白。
|
||||||
|
深层目录和长文件名可通过整棵树的横向滚动查看,不压缩名称。
|
||||||
|
目录列表最多显示前 2,000 个条目,截断时有提示。符号链接不作为目录递归展开。
|
||||||
|
切换页签保留目录展开、选中文件、源码模式、筛选及滚动位置。文件页签保存
|
||||||
|
工作区归属,展开与选中状态不会跨页面刷新保存。不接入 Git,
|
||||||
|
不编辑文件,也不自动把打开的文件加入对话上下文。文件右键菜单支持复制相对
|
||||||
|
或绝对路径、下载、在独立页签预览 Markdown/HTML/图片,以及通过「引用」插入
|
||||||
|
与输入框 `@` 选择文件相同的标签。仅当前工作区文件可引用,不自动发送消息。
|
||||||
|
|
||||||
|
嵌入式宿主通过 `rightPanel={{ items: ['files', 'review', 'sideTask'] }}` 启用。
|
||||||
|
此入口需要有效、可信且无歧义的 daemon 工作区归属。
|
||||||
|
|
||||||
|
Excel(`.xlsx`)支持内联预览,也可通过右键「预览」在独立页签打开;重复打开同一文件复用页签,其他 Excel 格式仍仅支持下载。
|
||||||
|
文件树源码预览在读取前限制为 4 MiB,更大的文件请通过右键菜单下载。
|
||||||
|
导入失败仅在凭据可恢复的独立页面中提供重载;嵌入宿主或仅内存凭据时提供关闭文件,保留对话和宿主页面。
|
||||||
|
|
||||||
## 开发网页预览
|
## 开发网页预览
|
||||||
|
|
||||||
独立 Web Shell 的右侧面板提供「网页预览」。先通过终端启动开发服务器,
|
独立 Web Shell 的右侧面板提供「网页预览」。先通过终端启动开发服务器,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
const notificationScrollToBottom = vi.hoisted(() => vi.fn());
|
const notificationScrollToBottom = vi.hoisted(() => vi.fn());
|
||||||
|
const editorAddTags = vi.hoisted(() => vi.fn());
|
||||||
import type {
|
import type {
|
||||||
ContextUsageControls,
|
ContextUsageControls,
|
||||||
RegisterContextUsageControls,
|
RegisterContextUsageControls,
|
||||||
@@ -797,6 +799,7 @@ const {
|
|||||||
latestSettingsState: null as {
|
latestSettingsState: null as {
|
||||||
settings: DaemonSettingDescriptor[];
|
settings: DaemonSettingDescriptor[];
|
||||||
} | null,
|
} | null,
|
||||||
|
artifactPanelExtra: null as ReactNode,
|
||||||
captureArtifactPanelOnly: false,
|
captureArtifactPanelOnly: false,
|
||||||
latestArtifactPanelProps: null as React.ComponentProps<
|
latestArtifactPanelProps: null as React.ComponentProps<
|
||||||
typeof ArtifactPanel
|
typeof ArtifactPanel
|
||||||
@@ -897,7 +900,10 @@ vi.mock('./components/artifacts/ArtifactPanel', async (importOriginal) => {
|
|||||||
ArtifactPanel: (props: React.ComponentProps<typeof ArtifactPanel>) => {
|
ArtifactPanel: (props: React.ComponentProps<typeof ArtifactPanel>) => {
|
||||||
testState.latestArtifactPanelProps = props;
|
testState.latestArtifactPanelProps = props;
|
||||||
return testState.captureArtifactPanelOnly ? null : (
|
return testState.captureArtifactPanelOnly ? null : (
|
||||||
<actual.ArtifactPanel {...props} />
|
<>
|
||||||
|
<actual.ArtifactPanel {...props} />
|
||||||
|
{testState.artifactPanelExtra}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1159,6 +1165,7 @@ vi.mock('./components/ChatEditor', async () => {
|
|||||||
props: ChatEditorTestProps,
|
props: ChatEditorTestProps,
|
||||||
ref: React.ForwardedRef<{
|
ref: React.ForwardedRef<{
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
addTags: (tags: unknown, options?: unknown) => void;
|
||||||
hasAttachments: () => boolean;
|
hasAttachments: () => boolean;
|
||||||
hasInput: () => boolean;
|
hasInput: () => boolean;
|
||||||
insertText: (text: string) => void;
|
insertText: (text: string) => void;
|
||||||
@@ -1209,6 +1216,7 @@ vi.mock('./components/ChatEditor', async () => {
|
|||||||
testState.inputAnnotations?.length,
|
testState.inputAnnotations?.length,
|
||||||
),
|
),
|
||||||
hasInput: () => testState.prompt.trim().length > 0,
|
hasInput: () => testState.prompt.trim().length > 0,
|
||||||
|
addTags: editorAddTags,
|
||||||
insertText: editorInsertText,
|
insertText: editorInsertText,
|
||||||
getText: () => testState.prompt,
|
getText: () => testState.prompt,
|
||||||
setText: (text) => {
|
setText: (text) => {
|
||||||
@@ -9861,6 +9869,36 @@ describe('artifact panel fullscreen', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(document.activeElement).toBe(last);
|
expect(document.activeElement).toBe(last);
|
||||||
|
|
||||||
|
const treeHost = document.createElement('div');
|
||||||
|
const treeShadow = treeHost.attachShadow({ mode: 'open' });
|
||||||
|
const treeRow = document.createElement('button');
|
||||||
|
treeShadow.appendChild(treeRow);
|
||||||
|
surface!.appendChild(treeHost);
|
||||||
|
first.focus();
|
||||||
|
await act(async () => {
|
||||||
|
first.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
shiftKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(treeShadow.activeElement).toBe(treeRow);
|
||||||
|
await act(async () => {
|
||||||
|
treeRow.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(first);
|
||||||
|
treeHost.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pulls focus back into the docked fullscreen surface when it escapes', async () => {
|
it('pulls focus back into the docked fullscreen surface when it escapes', async () => {
|
||||||
@@ -9896,6 +9934,16 @@ describe('artifact panel fullscreen', () => {
|
|||||||
exitFullscreen!.focus();
|
exitFullscreen!.focus();
|
||||||
expect(document.activeElement).toBe(exitFullscreen);
|
expect(document.activeElement).toBe(exitFullscreen);
|
||||||
|
|
||||||
|
const treeHost = document.createElement('div');
|
||||||
|
const treeShadow = treeHost.attachShadow({ mode: 'open' });
|
||||||
|
const treeRow = document.createElement('button');
|
||||||
|
treeRow.setAttribute('role', 'treeitem');
|
||||||
|
treeShadow.appendChild(treeRow);
|
||||||
|
surface!.appendChild(treeHost);
|
||||||
|
treeRow.focus({ preventScroll: true });
|
||||||
|
expect(treeShadow.activeElement).toBe(treeRow);
|
||||||
|
treeHost.remove();
|
||||||
|
|
||||||
// A Tab past the preview's last focusable lands focus natively outside
|
// A Tab past the preview's last focusable lands focus natively outside
|
||||||
// the surface: the keydown that moved it never reached the surface's
|
// the surface: the keydown that moved it never reached the surface's
|
||||||
// Tab-wrap handler. Pull the focus back so the keyboard stays the
|
// Tab-wrap handler. Pull the focus back so the keyboard stays the
|
||||||
@@ -9939,6 +9987,110 @@ describe('artifact panel fullscreen', () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tabs into and out of shadow-root rows in the floating panel', async () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
testState.artifactPanelExtra = createPortal(
|
||||||
|
<div role="dialog">
|
||||||
|
<button data-testid="portalled-dialog-button">Dialog control</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
const { container } = renderApp();
|
||||||
|
await flush();
|
||||||
|
act(() =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Toggle right panel"]',
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
const surface = document.querySelector<HTMLElement>(
|
||||||
|
'[data-slot="drawer-content"] aside[aria-label="Right panel"]',
|
||||||
|
)!;
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
const host = document.createElement('div');
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.setAttribute('role', 'treeitem');
|
||||||
|
shadow.appendChild(row);
|
||||||
|
const trailing = document.createElement('button');
|
||||||
|
trailing.textContent = 'after shadow';
|
||||||
|
surface.append(input, host, trailing);
|
||||||
|
const tab = async (target: HTMLElement, shiftKey = false) => {
|
||||||
|
await act(async () => {
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
shiftKey,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
input.focus();
|
||||||
|
await tab(input);
|
||||||
|
expect(shadow.activeElement).toBe(row);
|
||||||
|
await tab(row, true);
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
row.focus();
|
||||||
|
await tab(row);
|
||||||
|
expect(document.activeElement).toBe(trailing);
|
||||||
|
const prevented = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
input.focus();
|
||||||
|
prevented.preventDefault();
|
||||||
|
await act(async () => {
|
||||||
|
input.dispatchEvent(prevented);
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
const portalEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
document
|
||||||
|
.querySelector('[data-testid="portalled-dialog-button"]')!
|
||||||
|
.dispatchEvent(portalEvent);
|
||||||
|
});
|
||||||
|
expect(portalEvent.defaultPrevented).toBe(false);
|
||||||
|
const reversePortalEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
shiftKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
document
|
||||||
|
.querySelector('[data-testid="portalled-dialog-button"]')!
|
||||||
|
.dispatchEvent(reversePortalEvent);
|
||||||
|
});
|
||||||
|
expect(reversePortalEvent.defaultPrevented).toBe(false);
|
||||||
|
trailing.focus();
|
||||||
|
await tab(trailing);
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
surface.querySelector('button:not([disabled])'),
|
||||||
|
);
|
||||||
|
host.remove();
|
||||||
|
input.remove();
|
||||||
|
trailing.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the docked fullscreen surface keyboard-operable in shadow-DOM portal mode', async () => {
|
it('keeps the docked fullscreen surface keyboard-operable in shadow-DOM portal mode', async () => {
|
||||||
const { container } = renderApp({ shadowDom: { portals: true } });
|
const { container } = renderApp({ shadowDom: { portals: true } });
|
||||||
await flush();
|
await flush();
|
||||||
@@ -11685,6 +11837,7 @@ beforeEach(() => {
|
|||||||
testState.latestWorkflowRunsProps = null;
|
testState.latestWorkflowRunsProps = null;
|
||||||
testState.latestSplitViewProps = null;
|
testState.latestSplitViewProps = null;
|
||||||
testState.latestArtifactPanelProps = null;
|
testState.latestArtifactPanelProps = null;
|
||||||
|
testState.artifactPanelExtra = null;
|
||||||
testState.captureArtifactPanelOnly = false;
|
testState.captureArtifactPanelOnly = false;
|
||||||
rawEnqueuePrompt.mockClear();
|
rawEnqueuePrompt.mockClear();
|
||||||
editorClear.mockClear();
|
editorClear.mockClear();
|
||||||
@@ -16754,6 +16907,262 @@ describe('App session callbacks', () => {
|
|||||||
expect(header?.parentElement?.contains(artifactDock ?? null)).toBe(false);
|
expect(header?.parentElement?.contains(artifactDock ?? null)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'keeps Files ownership distinct across workspace switches (advertised=%s)',
|
||||||
|
async (advertised) => {
|
||||||
|
const workspaces = [
|
||||||
|
{ id: 'first', cwd: '/tmp/project', primary: true, trusted: true },
|
||||||
|
{ id: 'second', cwd: '/tmp/second', primary: false, trusted: true },
|
||||||
|
];
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
...(advertised ? { workspaces } : {}),
|
||||||
|
} as typeof mockWorkspace.capabilities;
|
||||||
|
testState.captureArtifactPanelOnly = true;
|
||||||
|
const { container, rerender } = renderApp({
|
||||||
|
rightPanel: { items: ['files'] },
|
||||||
|
});
|
||||||
|
act(() =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Toggle right panel"]',
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
|
||||||
|
const firstId = testState.latestArtifactPanelProps?.activeTabId;
|
||||||
|
mockConnection.workspaceCwd = '/tmp/second';
|
||||||
|
if (!advertised)
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/tmp/second',
|
||||||
|
} as typeof mockWorkspace.capabilities;
|
||||||
|
rerender();
|
||||||
|
await flush();
|
||||||
|
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
|
||||||
|
expect(testState.latestArtifactPanelProps?.activeTabId).not.toBe(firstId);
|
||||||
|
expect(testState.latestArtifactPanelProps?.tabs).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'files',
|
||||||
|
workspaceCwd: '/tmp/second',
|
||||||
|
id: advertised ? 'files:second' : 'files:/tmp/second',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('references a file inline and exits docked fullscreen without closing Files', async () => {
|
||||||
|
editorAddTags.mockClear();
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
workspaces: [
|
||||||
|
{ id: 'owner', cwd: '/tmp/project', primary: true, trusted: true },
|
||||||
|
],
|
||||||
|
} as typeof mockWorkspace.capabilities;
|
||||||
|
testState.captureArtifactPanelOnly = true;
|
||||||
|
const { container } = renderApp({ rightPanel: { items: ['files'] } });
|
||||||
|
act(() =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Toggle right panel"]',
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
|
||||||
|
act(() => testState.latestArtifactPanelProps?.onToggleFullscreen?.());
|
||||||
|
expect(testState.latestArtifactPanelProps?.fullscreen).toBe(true);
|
||||||
|
expect(testState.latestArtifactPanelProps?.onReferenceFile).toBeDefined();
|
||||||
|
act(() =>
|
||||||
|
testState.latestArtifactPanelProps?.onReferenceFile?.('src/app.ts'),
|
||||||
|
);
|
||||||
|
expect(testState.latestArtifactPanelProps?.fullscreen).toBe(false);
|
||||||
|
expect(testState.latestArtifactPanelProps?.tabs).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: 'files' }),
|
||||||
|
);
|
||||||
|
expect(editorAddTags).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({ serialized: '@src/app.ts' })],
|
||||||
|
{ placement: 'inline', position: 'end' },
|
||||||
|
);
|
||||||
|
act(() => testState.latestChatEditorProps?.onSubmit('/model'));
|
||||||
|
await flush();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="model-select"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(testState.latestArtifactPanelProps?.onReferenceFile).toBeUndefined();
|
||||||
|
await act(async () =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>('[data-testid="model-select"]')
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
await act(async () =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="open-sessions-overview"]',
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
await act(async () =>
|
||||||
|
testState.latestSessionOverviewProps?.onOpenSplit?.([
|
||||||
|
'session-1',
|
||||||
|
'session-2',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(testState.latestSplitViewProps).not.toBeNull();
|
||||||
|
expect(testState.latestArtifactPanelProps?.onReferenceFile).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens one files tab per workspace when configured', () => {
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: 'project-workspace',
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
primary: true,
|
||||||
|
trusted: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as typeof mockWorkspace.capabilities;
|
||||||
|
testState.captureArtifactPanelOnly = true;
|
||||||
|
const { container } = renderApp({ rightPanel: { items: ['files'] } });
|
||||||
|
act(() =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Toggle right panel"]',
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
expect(testState.latestArtifactPanelProps?.onOpenFiles).toBeDefined();
|
||||||
|
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
|
||||||
|
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
|
||||||
|
expect(testState.latestArtifactPanelProps?.tabs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'files',
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
title: 'Files',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(testState.latestArtifactPanelProps?.activeTabId).toBe(
|
||||||
|
testState.latestArtifactPanelProps?.tabs[0]?.id,
|
||||||
|
);
|
||||||
|
act(() =>
|
||||||
|
testState.latestArtifactPanelProps?.onOpenFilePreview(
|
||||||
|
'README.md',
|
||||||
|
'/tmp/project',
|
||||||
|
'project-workspace',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(testState.latestArtifactPanelProps?.tabs).toEqual([
|
||||||
|
expect.objectContaining({ kind: 'files' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'file',
|
||||||
|
workspacePath: 'README.md',
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
workspaceId: 'project-workspace',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(testState.latestArtifactPanelProps?.tabs[1]).not.toHaveProperty(
|
||||||
|
'previewContent',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['disabled', 'untrusted', 'unknown'] as const)(
|
||||||
|
'does not expose files for an unavailable workspace (%s)',
|
||||||
|
async (reason) => {
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
workspaces:
|
||||||
|
reason === 'unknown'
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'owner',
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
primary: true,
|
||||||
|
trusted: reason !== 'untrusted',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as typeof mockWorkspace.capabilities;
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'qwen-code-web-shell-right-panel-state',
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
['/tmp/project\0session-1']: {
|
||||||
|
open: true,
|
||||||
|
activeTabId: 'files:stored',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: 'files:stored',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
workspaceId: 'owner',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
testState.captureArtifactPanelOnly = true;
|
||||||
|
const { container } = renderApp({
|
||||||
|
rightPanel: { items: reason === 'disabled' ? ['review'] : ['files'] },
|
||||||
|
});
|
||||||
|
act(() =>
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Toggle right panel"]',
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
expect(
|
||||||
|
testState.latestArtifactPanelProps?.tabs.some(
|
||||||
|
(tab) => tab.kind === 'files',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(testState.latestArtifactPanelProps).toBeDefined();
|
||||||
|
expect(testState.latestArtifactPanelProps?.onOpenFiles).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['/tmp/project', '', 42, null])(
|
||||||
|
'round trips valid persisted files tabs and rejects malformed cwd (%s)',
|
||||||
|
async (workspaceCwd) => {
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/tmp/project',
|
||||||
|
workspaces: [
|
||||||
|
{ id: 'owner', cwd: '/tmp/project', primary: true, trusted: true },
|
||||||
|
],
|
||||||
|
} as typeof mockWorkspace.capabilities;
|
||||||
|
testState.captureArtifactPanelOnly = true;
|
||||||
|
const tab = {
|
||||||
|
id: 'files:stored',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd,
|
||||||
|
workspaceId: 'owner',
|
||||||
|
};
|
||||||
|
const key = 'qwen-code-web-shell-right-panel-state';
|
||||||
|
const sessionKey = '/tmp/project\0session-1';
|
||||||
|
window.localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
[sessionKey]: { open: true, activeTabId: tab.id, tabs: [tab] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp({ rightPanel: { items: ['files'] } });
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
const expected = workspaceCwd === '/tmp/project' ? [tab] : [];
|
||||||
|
expect(testState.latestArtifactPanelProps?.tabs).toEqual(expected);
|
||||||
|
expect(
|
||||||
|
JSON.parse(window.localStorage.getItem(key) ?? '{}')[sessionKey]?.tabs,
|
||||||
|
).toEqual(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('shows the manual terminal action when configured and advertised', () => {
|
it('shows the manual terminal action when configured and advertised', () => {
|
||||||
mockConnection.capabilities.features = ['web_terminal'];
|
mockConnection.capabilities.features = ['web_terminal'];
|
||||||
const { container } = renderApp({
|
const { container } = renderApp({
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
createComposerTagForItem,
|
||||||
|
fileReferenceInsertText,
|
||||||
|
FILE_PROVIDER_ID,
|
||||||
|
sanitizeAtMentionItem,
|
||||||
|
} from './hooks/useAtMentionSources';
|
||||||
import {
|
import {
|
||||||
isModelSetupCommand,
|
isModelSetupCommand,
|
||||||
resolveModelManagement,
|
resolveModelManagement,
|
||||||
@@ -564,9 +570,9 @@ function isWebTerminalTarget(event: Event): boolean {
|
|||||||
// so a docked panel mounting under an open DialogShell would pause the
|
// so a docked panel mounting under an open DialogShell would pause the
|
||||||
// modal's trap. The surface covers the viewport, so the keyboard is the only
|
// modal's trap. The surface covers the viewport, so the keyboard is the only
|
||||||
// escape route; wrap it at the tabbable edges like FocusScope's loop does.
|
// escape route; wrap it at the tabbable edges like FocusScope's loop does.
|
||||||
function getFullscreenSurfaceTabEdges(
|
function getArtifactPanelTabStops(
|
||||||
container: HTMLElement,
|
container: HTMLElement | ShadowRoot,
|
||||||
): [HTMLElement | null, HTMLElement | null] {
|
): HTMLElement[] {
|
||||||
const candidates: HTMLElement[] = [];
|
const candidates: HTMLElement[] = [];
|
||||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
||||||
acceptNode(node) {
|
acceptNode(node) {
|
||||||
@@ -584,20 +590,23 @@ function getFullscreenSurfaceTabEdges(
|
|||||||
) {
|
) {
|
||||||
return NodeFilter.FILTER_SKIP;
|
return NodeFilter.FILTER_SKIP;
|
||||||
}
|
}
|
||||||
return element.tabIndex >= 0
|
return element.tabIndex >= 0 || element.shadowRoot
|
||||||
? NodeFilter.FILTER_ACCEPT
|
? NodeFilter.FILTER_ACCEPT
|
||||||
: NodeFilter.FILTER_SKIP;
|
: NodeFilter.FILTER_SKIP;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
while (walker.nextNode()) {
|
while (walker.nextNode()) {
|
||||||
candidates.push(walker.currentNode as HTMLElement);
|
const element = walker.currentNode as HTMLElement;
|
||||||
|
if (element.tabIndex >= 0) candidates.push(element);
|
||||||
|
if (element.shadowRoot) {
|
||||||
|
candidates.push(...getArtifactPanelTabStops(element.shadowRoot));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const visible = candidates.filter((element) =>
|
return candidates.filter((element) =>
|
||||||
typeof element.checkVisibility === 'function'
|
typeof element.checkVisibility === 'function'
|
||||||
? element.checkVisibility({ checkVisibilityCSS: true })
|
? element.checkVisibility({ checkVisibilityCSS: true })
|
||||||
: true,
|
: true,
|
||||||
);
|
);
|
||||||
return [visible[0] ?? null, visible[visible.length - 1] ?? null];
|
|
||||||
}
|
}
|
||||||
const DEFAULT_COMPOSER_TOOLBAR_ACTIONS = [
|
const DEFAULT_COMPOSER_TOOLBAR_ACTIONS = [
|
||||||
'approvalMode',
|
'approvalMode',
|
||||||
@@ -1937,6 +1946,7 @@ interface ArtifactPanelPersistedState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PersistedArtifactPanelTab =
|
type PersistedArtifactPanelTab =
|
||||||
|
| Extract<ArtifactPanelTab, { kind: 'files' }>
|
||||||
| Extract<ArtifactPanelTab, { kind: 'web_preview' }>
|
| Extract<ArtifactPanelTab, { kind: 'web_preview' }>
|
||||||
| Omit<Extract<ArtifactPanelTab, { kind: 'turn_calls' }>, 'promptLabel'>
|
| Omit<Extract<ArtifactPanelTab, { kind: 'turn_calls' }>, 'promptLabel'>
|
||||||
| Pick<
|
| Pick<
|
||||||
@@ -2067,6 +2077,15 @@ function parsePersistedArtifactPanelTab(
|
|||||||
}
|
}
|
||||||
const common = { id: tab['id'], title: tab['title'] };
|
const common = { id: tab['id'], title: tab['title'] };
|
||||||
switch (tab['kind']) {
|
switch (tab['kind']) {
|
||||||
|
case 'files':
|
||||||
|
if (typeof tab['workspaceCwd'] !== 'string' || !tab['workspaceCwd'])
|
||||||
|
return;
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: 'files',
|
||||||
|
workspaceCwd: tab['workspaceCwd'],
|
||||||
|
workspaceId: tab['workspaceId'] as string | undefined,
|
||||||
|
};
|
||||||
case 'turn_calls':
|
case 'turn_calls':
|
||||||
if (
|
if (
|
||||||
tab['id'] !== 'turn_calls' ||
|
tab['id'] !== 'turn_calls' ||
|
||||||
@@ -2244,6 +2263,8 @@ function serializeArtifactPanelTabs(
|
|||||||
return tabs.flatMap((tab): PersistedArtifactPanelTab[] => {
|
return tabs.flatMap((tab): PersistedArtifactPanelTab[] => {
|
||||||
const { id, title } = tab;
|
const { id, title } = tab;
|
||||||
switch (tab.kind) {
|
switch (tab.kind) {
|
||||||
|
case 'files':
|
||||||
|
return [tab];
|
||||||
case 'web_preview':
|
case 'web_preview':
|
||||||
return [
|
return [
|
||||||
{ id, title, kind: tab.kind, url: tab.url, viewport: tab.viewport },
|
{ id, title, kind: tab.kind, url: tab.url, viewport: tab.viewport },
|
||||||
@@ -5188,6 +5209,15 @@ export function App({
|
|||||||
Boolean(connection.sessionId && connection.workspaceCwd) &&
|
Boolean(connection.sessionId && connection.workspaceCwd) &&
|
||||||
connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) ===
|
connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) ===
|
||||||
true;
|
true;
|
||||||
|
const filesAvailable =
|
||||||
|
workspaceContextActive &&
|
||||||
|
rightPanelItems.includes('files') &&
|
||||||
|
Boolean(
|
||||||
|
resolveArtifactWorkspaceOwner(
|
||||||
|
workspace.capabilities,
|
||||||
|
connection.workspaceCwd,
|
||||||
|
),
|
||||||
|
);
|
||||||
const webPreviewAvailable =
|
const webPreviewAvailable =
|
||||||
workspaceContextActive && rightPanelItems.includes('webPreview');
|
workspaceContextActive && rightPanelItems.includes('webPreview');
|
||||||
const trajectoryAvailable = rightPanelItems.includes('trajectory');
|
const trajectoryAvailable = rightPanelItems.includes('trajectory');
|
||||||
@@ -6563,6 +6593,7 @@ export function App({
|
|||||||
).some(
|
).some(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
(tab.kind === 'terminal' && webTerminalAvailable) ||
|
(tab.kind === 'terminal' && webTerminalAvailable) ||
|
||||||
|
(tab.kind === 'files' && filesAvailable) ||
|
||||||
(tab.kind === 'web_preview' && webPreviewAvailable) ||
|
(tab.kind === 'web_preview' && webPreviewAvailable) ||
|
||||||
(tab.kind === 'workflow' &&
|
(tab.kind === 'workflow' &&
|
||||||
tab.sessionId === connection.sessionId &&
|
tab.sessionId === connection.sessionId &&
|
||||||
@@ -6658,6 +6689,7 @@ export function App({
|
|||||||
const deferredPersistedTabs = (persisted?.tabs ?? []).filter(
|
const deferredPersistedTabs = (persisted?.tabs ?? []).filter(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
(tab.kind === 'terminal' && !webTerminalAvailable) ||
|
(tab.kind === 'terminal' && !webTerminalAvailable) ||
|
||||||
|
(tab.kind === 'files' && !filesAvailable) ||
|
||||||
(tab.kind === 'web_preview' && !webPreviewAvailable) ||
|
(tab.kind === 'web_preview' && !webPreviewAvailable) ||
|
||||||
(tab.kind === 'workflow' &&
|
(tab.kind === 'workflow' &&
|
||||||
tab.sessionId === connection.sessionId &&
|
tab.sessionId === connection.sessionId &&
|
||||||
@@ -6742,6 +6774,8 @@ export function App({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
case 'files':
|
||||||
|
return filesAvailable ? tab : undefined;
|
||||||
case 'web_preview':
|
case 'web_preview':
|
||||||
return webPreviewAvailable ? tab : undefined;
|
return webPreviewAvailable ? tab : undefined;
|
||||||
case 'file': {
|
case 'file': {
|
||||||
@@ -7005,6 +7039,7 @@ export function App({
|
|||||||
sessionActions,
|
sessionActions,
|
||||||
trajectoryAvailable,
|
trajectoryAvailable,
|
||||||
webTerminalAvailable,
|
webTerminalAvailable,
|
||||||
|
filesAvailable,
|
||||||
webPreviewAvailable,
|
webPreviewAvailable,
|
||||||
workspace.baseUrl,
|
workspace.baseUrl,
|
||||||
workspace.client,
|
workspace.client,
|
||||||
@@ -7048,6 +7083,36 @@ export function App({
|
|||||||
},
|
},
|
||||||
[getDefaultReviewPanelWidth],
|
[getDefaultReviewPanelWidth],
|
||||||
);
|
);
|
||||||
|
const openFilesPanel = useCallback(() => {
|
||||||
|
const owner = resolveArtifactWorkspaceOwner(
|
||||||
|
workspace.capabilities,
|
||||||
|
connection.workspaceCwd,
|
||||||
|
);
|
||||||
|
if (!filesAvailable || !owner) return;
|
||||||
|
rememberArtifactPanelTrigger();
|
||||||
|
const tab: ArtifactPanelTab = {
|
||||||
|
id: `files:${owner.id ?? owner.cwd}`,
|
||||||
|
kind: 'files',
|
||||||
|
title: t('files.title'),
|
||||||
|
workspaceCwd: owner.cwd,
|
||||||
|
workspaceId: owner.id,
|
||||||
|
};
|
||||||
|
setArtifactPanelTabs((tabs) =>
|
||||||
|
tabs.some((item) => item.id === tab.id) ? tabs : [...tabs, tab],
|
||||||
|
);
|
||||||
|
setActiveArtifactPanelTabId(tab.id);
|
||||||
|
setArtifactPanelWidth((width) =>
|
||||||
|
Math.min(getMaxArtifactPanelWidth(), Math.max(width, 740)),
|
||||||
|
);
|
||||||
|
setArtifactPanelOpen(true);
|
||||||
|
}, [
|
||||||
|
connection.workspaceCwd,
|
||||||
|
filesAvailable,
|
||||||
|
getMaxArtifactPanelWidth,
|
||||||
|
rememberArtifactPanelTrigger,
|
||||||
|
t,
|
||||||
|
workspace.capabilities,
|
||||||
|
]);
|
||||||
const openSubagentPanelForSession = useCallback(
|
const openSubagentPanelForSession = useCallback(
|
||||||
(tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => {
|
(tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => {
|
||||||
if (getSubagentDetailsUnavailableReason(tool)) return;
|
if (getSubagentDetailsUnavailableReason(tool)) return;
|
||||||
@@ -7404,16 +7469,20 @@ export function App({
|
|||||||
);
|
);
|
||||||
const openFilePreview = useCallback(
|
const openFilePreview = useCallback(
|
||||||
(
|
(
|
||||||
change: TurnOutputFileChange,
|
change: TurnOutputFileChange | string,
|
||||||
previewWorkspaceCwd?: string,
|
previewWorkspaceCwd?: string,
|
||||||
previewWorkspaceId?: string,
|
previewWorkspaceId?: string,
|
||||||
) => {
|
) => {
|
||||||
const previewContent = getFileChangePreviewContent(change);
|
const path = typeof change === 'string' ? change : change.path;
|
||||||
|
const previewContent =
|
||||||
|
typeof change === 'string'
|
||||||
|
? undefined
|
||||||
|
: getFileChangePreviewContent(change);
|
||||||
const tab: ArtifactPanelTab = {
|
const tab: ArtifactPanelTab = {
|
||||||
id: `file:${previewWorkspaceCwd ?? ''}:${change.path}`,
|
id: `file:${previewWorkspaceCwd ?? ''}:${path}`,
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
title: displayPath(change.path, previewWorkspaceCwd),
|
title: displayPath(path, previewWorkspaceCwd),
|
||||||
workspacePath: change.path,
|
workspacePath: path,
|
||||||
...(previewWorkspaceCwd ? { workspaceCwd: previewWorkspaceCwd } : {}),
|
...(previewWorkspaceCwd ? { workspaceCwd: previewWorkspaceCwd } : {}),
|
||||||
...(previewWorkspaceId ? { workspaceId: previewWorkspaceId } : {}),
|
...(previewWorkspaceId ? { workspaceId: previewWorkspaceId } : {}),
|
||||||
...(previewContent !== undefined ? { previewContent } : {}),
|
...(previewContent !== undefined ? { previewContent } : {}),
|
||||||
@@ -9140,7 +9209,11 @@ export function App({
|
|||||||
const target =
|
const target =
|
||||||
(event.composedPath()[0] as Element | undefined) ??
|
(event.composedPath()[0] as Element | undefined) ??
|
||||||
(event.target as Element | null);
|
(event.target as Element | null);
|
||||||
if (!target || surface.contains(target) || target.contains(surface)) {
|
if (
|
||||||
|
!target ||
|
||||||
|
event.composedPath().includes(surface) ||
|
||||||
|
target.contains(surface)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The elevated toast host and any Radix layer opened from the panel
|
// The elevated toast host and any Radix layer opened from the panel
|
||||||
@@ -9178,8 +9251,9 @@ export function App({
|
|||||||
}, [dockedFullscreenActive, portalRoot]);
|
}, [dockedFullscreenActive, portalRoot]);
|
||||||
const handleArtifactPanelSurfaceKeyDown = useCallback(
|
const handleArtifactPanelSurfaceKeyDown = useCallback(
|
||||||
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||||
if (!artifactPanelFullscreen) return;
|
if (!artifactPanelFullscreen && !useFloatingArtifactPanel) return;
|
||||||
if (
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
event.key !== 'Tab' ||
|
event.key !== 'Tab' ||
|
||||||
event.altKey ||
|
event.altKey ||
|
||||||
event.ctrlKey ||
|
event.ctrlKey ||
|
||||||
@@ -9187,12 +9261,37 @@ export function App({
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [first, last] = getFullscreenSurfaceTabEdges(event.currentTarget);
|
if (!event.nativeEvent.composedPath().includes(event.currentTarget))
|
||||||
const focused = getShadowAwareActiveElement(event.currentTarget);
|
return;
|
||||||
|
const candidates = getArtifactPanelTabStops(event.currentTarget);
|
||||||
|
const first = candidates[0];
|
||||||
|
const last = candidates[candidates.length - 1];
|
||||||
|
let focused = getShadowAwareActiveElement(event.currentTarget);
|
||||||
|
while (focused?.shadowRoot?.activeElement) {
|
||||||
|
focused = focused.shadowRoot.activeElement;
|
||||||
|
}
|
||||||
if (!first || !last) {
|
if (!first || !last) {
|
||||||
if (focused === event.currentTarget) event.preventDefault();
|
if (focused === event.currentTarget) event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Radix's drawer loop only sees light DOM, so own Tab before it can
|
||||||
|
// wrap from the filter past the file tree's shadow-root rows.
|
||||||
|
if (useFloatingArtifactPanel) {
|
||||||
|
const index = candidates.indexOf(focused as HTMLElement);
|
||||||
|
const next =
|
||||||
|
index < 0
|
||||||
|
? event.shiftKey
|
||||||
|
? last
|
||||||
|
: first
|
||||||
|
: candidates[
|
||||||
|
(index + (event.shiftKey ? -1 : 1) + candidates.length) %
|
||||||
|
candidates.length
|
||||||
|
];
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
next?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!event.shiftKey && focused === last) {
|
if (!event.shiftKey && focused === last) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
first.focus();
|
first.focus();
|
||||||
@@ -9204,7 +9303,7 @@ export function App({
|
|||||||
last.focus();
|
last.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[artifactPanelFullscreen],
|
[artifactPanelFullscreen, useFloatingArtifactPanel],
|
||||||
);
|
);
|
||||||
// The drawer's Radix dismiss layer only checks `event.key === 'Escape'`
|
// The drawer's Radix dismiss layer only checks `event.key === 'Escape'`
|
||||||
// (no isComposing guard), so while composing in a panel input an Escape
|
// (no isComposing guard), so while composing in a panel input an Escape
|
||||||
@@ -18529,7 +18628,18 @@ export function App({
|
|||||||
if (escArmedActionRef.current !== null) {
|
if (escArmedActionRef.current !== null) {
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
}
|
}
|
||||||
if (e.key === 'Tab' && e.shiftKey && !live.interactionBlocked) {
|
if (
|
||||||
|
e.key === 'Tab' &&
|
||||||
|
e.shiftKey &&
|
||||||
|
!live.interactionBlocked &&
|
||||||
|
!e
|
||||||
|
.composedPath()
|
||||||
|
.some(
|
||||||
|
(node) =>
|
||||||
|
node instanceof Element &&
|
||||||
|
node.matches('[role="dialog"], [role="alertdialog"]'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
live.handleCycleMode();
|
live.handleCycleMode();
|
||||||
}
|
}
|
||||||
@@ -20001,6 +20111,34 @@ export function App({
|
|||||||
onSelectTab: selectArtifactPanelTab,
|
onSelectTab: selectArtifactPanelTab,
|
||||||
onCloseTab: closeArtifactPanelTab,
|
onCloseTab: closeArtifactPanelTab,
|
||||||
onOpenFilePreview: openFilePreview,
|
onOpenFilePreview: openFilePreview,
|
||||||
|
onOpenFiles: filesAvailable ? openFilesPanel : undefined,
|
||||||
|
onReferenceFile:
|
||||||
|
workspaceContextActive && !dialogOpen && mainView === 'chat'
|
||||||
|
? (path: string) => {
|
||||||
|
const tag = createComposerTagForItem(
|
||||||
|
FILE_PROVIDER_ID,
|
||||||
|
sanitizeAtMentionItem({
|
||||||
|
id: `file:${path}`,
|
||||||
|
label: path.split('/').at(-1) ?? path,
|
||||||
|
description: path,
|
||||||
|
kind: 'insert',
|
||||||
|
fileKind: 'file',
|
||||||
|
}),
|
||||||
|
fileReferenceInsertText(path),
|
||||||
|
);
|
||||||
|
if (!tag) return;
|
||||||
|
if (useFloatingArtifactPanel) {
|
||||||
|
closeArtifactPanel();
|
||||||
|
} else {
|
||||||
|
setArtifactPanelFullscreen(false);
|
||||||
|
}
|
||||||
|
editorRef.current?.addTags([tag], {
|
||||||
|
placement: 'inline',
|
||||||
|
position: 'end',
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => editorRef.current?.focus());
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onOpenWebPreview: workspaceContextActive ? openWebPreviewTab : undefined,
|
onOpenWebPreview: workspaceContextActive ? openWebPreviewTab : undefined,
|
||||||
onWebPreviewChange: updateWebPreviewTab,
|
onWebPreviewChange: updateWebPreviewTab,
|
||||||
latestReviewAvailable: latestReviewChanges.length > 0,
|
latestReviewAvailable: latestReviewChanges.length > 0,
|
||||||
@@ -23086,6 +23224,15 @@ export function App({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const target = event.composedPath()[0];
|
||||||
|
if (
|
||||||
|
target instanceof HTMLInputElement &&
|
||||||
|
target.hasAttribute('data-workspace-file-filter') &&
|
||||||
|
target.value
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Fullscreen is the topmost surface: Escape shrinks the
|
// Fullscreen is the topmost surface: Escape shrinks the
|
||||||
// panel back to its drawer width. Without this the
|
// panel back to its drawer width. Without this the
|
||||||
// drawer's dismiss layer would close the panel instead.
|
// drawer's dismiss layer would close the panel instead.
|
||||||
@@ -23095,15 +23242,20 @@ export function App({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DrawerTitle className="sr-only">Right panel</DrawerTitle>
|
<DrawerTitle className="sr-only">Right panel</DrawerTitle>
|
||||||
<WebShellCustomizationProvider value={customization}>
|
<div
|
||||||
<ArtifactPanel
|
className="flex min-h-0 flex-1"
|
||||||
{...artifactPanelSharedProps}
|
onKeyDown={handleArtifactPanelSurfaceKeyDown}
|
||||||
onOpenTerminal={
|
>
|
||||||
webTerminalAvailable ? openTerminalTab : undefined
|
<WebShellCustomizationProvider value={customization}>
|
||||||
}
|
<ArtifactPanel
|
||||||
variant="drawer"
|
{...artifactPanelSharedProps}
|
||||||
/>
|
onOpenTerminal={
|
||||||
</WebShellCustomizationProvider>
|
webTerminalAvailable ? openTerminalTab : undefined
|
||||||
|
}
|
||||||
|
variant="drawer"
|
||||||
|
/>
|
||||||
|
</WebShellCustomizationProvider>
|
||||||
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import postcss, { type Rule } from 'postcss';
|
import postcss, { type Rule } from 'postcss';
|
||||||
|
import { FILES_MESSAGES_EN } from './components/artifacts/files-messages';
|
||||||
import { LIVE_MESSAGES_EN } from './live/messages';
|
import { LIVE_MESSAGES_EN } from './live/messages';
|
||||||
import { COLLAB_MESSAGES_EN } from './components/workspace-agents/messages';
|
import { COLLAB_MESSAGES_EN } from './components/workspace-agents/messages';
|
||||||
|
|
||||||
@@ -133,6 +134,39 @@ describe('build artifact — package boundary', () => {
|
|||||||
expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/);
|
expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bundles Pierre highlighter dependencies instead of resolving the host Shiki major', () => {
|
||||||
|
const notices = readFileSync(resolve(DIST_DIR, '../NOTICES.txt'), 'utf8');
|
||||||
|
for (const name of ['diffs', 'trees', 'theming', 'theme']) {
|
||||||
|
expect(notices).toContain(`@pierre/${name}@`);
|
||||||
|
}
|
||||||
|
const treeAttribution = notices
|
||||||
|
.split('@pierre/trees@')[1]!
|
||||||
|
.split(
|
||||||
|
'============================================================',
|
||||||
|
)[0]!;
|
||||||
|
// This is the bundled tree's NOTICE credit, not a separate dependency entry.
|
||||||
|
expect(treeAttribution).toContain('Original license for');
|
||||||
|
expect(treeAttribution).toContain('headless-tree/core');
|
||||||
|
expect(notices).toContain('shiki@4.');
|
||||||
|
expect(readPackageJavascript()).not.toMatch(
|
||||||
|
/(?:from\s*|import\s*\()\s*["']@pierre\//,
|
||||||
|
);
|
||||||
|
const previews = readdirSync(DIST_DIR).filter((name) =>
|
||||||
|
/^PierreFilePreview-.*\.js$/.test(name),
|
||||||
|
);
|
||||||
|
expect(previews).toHaveLength(1);
|
||||||
|
const preview = readFileSync(resolve(DIST_DIR, previews[0]!), 'utf8');
|
||||||
|
expect(preview).not.toMatch(/from\s+["']shiki["']/);
|
||||||
|
expect(preview).toContain('createJavaScriptRegexEngine');
|
||||||
|
const hostHighlighter = readdirSync(DIST_DIR).find((name) =>
|
||||||
|
/^codeHighlighter-.*\.js$/.test(name),
|
||||||
|
);
|
||||||
|
expect(hostHighlighter).toBeDefined();
|
||||||
|
expect(readFileSync(resolve(DIST_DIR, hostHighlighter!), 'utf8')).toContain(
|
||||||
|
'from "shiki"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes every component CSS rule to a WebShell root', () => {
|
it('scopes every component CSS rule to a WebShell root', () => {
|
||||||
const unscoped: string[] = [];
|
const unscoped: string[] = [];
|
||||||
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
||||||
@@ -497,6 +531,16 @@ describe('build artifact — transcript entry (#11031)', () => {
|
|||||||
expect(js).not.toContain('selectionCopyListWrapper');
|
expect(js).not.toContain('selectionCopyListWrapper');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes Files-only messages from the transcript entry', () => {
|
||||||
|
const bundle = readTranscriptBundle();
|
||||||
|
for (const key of [
|
||||||
|
'files.description',
|
||||||
|
'files.reloadUnavailable',
|
||||||
|
'files.sourceTooLarge',
|
||||||
|
] as const)
|
||||||
|
expect(bundle).not.toContain(FILES_MESSAGES_EN[key]);
|
||||||
|
});
|
||||||
|
|
||||||
it('carries no Live Voice strings and looks none up', () => {
|
it('carries no Live Voice strings and looks none up', () => {
|
||||||
// vite.lib.config.ts swaps client/live/messages.ts for an empty stub in
|
// vite.lib.config.ts swaps client/live/messages.ts for an empty stub in
|
||||||
// this build, which keeps ~200 entries out of every exported document. It
|
// this build, which keeps ~200 entries out of every exported document. It
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, StrictMode } from 'react';
|
import { act, StrictMode, useEffect } from 'react';
|
||||||
import { Blob as NodeBlob } from 'node:buffer';
|
import { Blob as NodeBlob } from 'node:buffer';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { EditorView } from 'codemirror';
|
import { EditorView } from 'codemirror';
|
||||||
@@ -38,6 +38,7 @@ const {
|
|||||||
readWorkspaceFile: vi.fn(),
|
readWorkspaceFile: vi.fn(),
|
||||||
readWorkspaceFileBytes: vi.fn(),
|
readWorkspaceFileBytes: vi.fn(),
|
||||||
fileStat: vi.fn(),
|
fileStat: vi.fn(),
|
||||||
|
dirList: vi.fn(),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
mockActions: {
|
mockActions: {
|
||||||
@@ -48,6 +49,7 @@ const {
|
|||||||
readFileBytes: vi.fn(),
|
readFileBytes: vi.fn(),
|
||||||
readWorkspaceFile: vi.fn(),
|
readWorkspaceFile: vi.fn(),
|
||||||
stat: vi.fn(),
|
stat: vi.fn(),
|
||||||
|
listDirectory: vi.fn(),
|
||||||
listScheduledTasks: vi.fn(),
|
listScheduledTasks: vi.fn(),
|
||||||
updateScheduledTask: vi.fn(),
|
updateScheduledTask: vi.fn(),
|
||||||
deleteScheduledTask: vi.fn(),
|
deleteScheduledTask: vi.fn(),
|
||||||
@@ -94,6 +96,70 @@ vi.mock(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reloadPolicy = vi.hoisted(() => ({
|
||||||
|
standalone: true,
|
||||||
|
token: undefined as string | undefined,
|
||||||
|
survives: true,
|
||||||
|
}));
|
||||||
|
vi.mock('../../config/daemon-target', () => ({
|
||||||
|
useDaemonTargetOptional: () =>
|
||||||
|
reloadPolicy.standalone ? { activeToken: reloadPolicy.token } : undefined,
|
||||||
|
}));
|
||||||
|
vi.mock('../../config/daemon', async (original) => ({
|
||||||
|
...(await original<typeof import('../../config/daemon')>()),
|
||||||
|
hasReloadSurvivableDaemonToken: () => reloadPolicy.survives,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fileBrowserProps = vi.hoisted(() => ({
|
||||||
|
loadError: undefined as Error | undefined,
|
||||||
|
sourceError: undefined as Error | undefined,
|
||||||
|
loadAttempts: 0,
|
||||||
|
previewPath: undefined as string | undefined,
|
||||||
|
current: undefined as
|
||||||
|
| { workspaceCwd: string; onReferenceFile?: (path: string) => void }
|
||||||
|
| undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./WorkspaceFileBrowser', () => {
|
||||||
|
const WorkspaceFileBrowser = (props: {
|
||||||
|
workspaceCwd: string;
|
||||||
|
onReferenceFile?: (path: string) => void;
|
||||||
|
actions: ArtifactWorkspaceTarget['actions'];
|
||||||
|
renderPreview: (
|
||||||
|
path: string,
|
||||||
|
source: boolean,
|
||||||
|
version: number,
|
||||||
|
) => React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
fileBrowserProps.current = props;
|
||||||
|
useEffect(() => {
|
||||||
|
void props.actions.listDirectory('.').catch(() => {});
|
||||||
|
}, [props.actions]);
|
||||||
|
return (
|
||||||
|
<div data-testid="workspace-file-browser" data-cwd={props.workspaceCwd}>
|
||||||
|
{fileBrowserProps.previewPath &&
|
||||||
|
props.renderPreview(fileBrowserProps.previewPath, true, 0)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get WorkspaceFileBrowser() {
|
||||||
|
fileBrowserProps.loadAttempts++;
|
||||||
|
if (fileBrowserProps.loadError) throw fileBrowserProps.loadError;
|
||||||
|
return WorkspaceFileBrowser;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./PierreFilePreview', () => ({
|
||||||
|
default: function MockFileSource() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (fileBrowserProps.sourceError) throw fileBrowserProps.sourceError;
|
||||||
|
}, []);
|
||||||
|
return <div data-testid="recovered-file-source" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../terminal/TerminalPanel', () => ({
|
vi.mock('../terminal/TerminalPanel', () => ({
|
||||||
TerminalPanel: ({ terminalId }: { terminalId: string }) => (
|
TerminalPanel: ({ terminalId }: { terminalId: string }) => (
|
||||||
<div data-testid="terminal-panel" data-terminal-id={terminalId} />
|
<div data-testid="terminal-panel" data-terminal-id={terminalId} />
|
||||||
@@ -123,8 +189,14 @@ const { useArtifactWorkspaceTarget } = await import(
|
|||||||
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
||||||
let latestArtifactWorkspaceTarget: ArtifactWorkspaceTarget | undefined;
|
let latestArtifactWorkspaceTarget: ArtifactWorkspaceTarget | undefined;
|
||||||
|
|
||||||
function ArtifactWorkspaceTargetProbe({ revision }: { revision: number }) {
|
function ArtifactWorkspaceTargetProbe({
|
||||||
latestArtifactWorkspaceTarget = useArtifactWorkspaceTarget('/secondary');
|
revision,
|
||||||
|
workspaceCwd = '/secondary',
|
||||||
|
}: {
|
||||||
|
revision: number;
|
||||||
|
workspaceCwd?: string;
|
||||||
|
}) {
|
||||||
|
latestArtifactWorkspaceTarget = useArtifactWorkspaceTarget(workspaceCwd);
|
||||||
return <span data-revision={revision} />;
|
return <span data-revision={revision} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +418,13 @@ function scheduledTaskPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
reloadPolicy.standalone = true;
|
||||||
|
reloadPolicy.token = undefined;
|
||||||
|
reloadPolicy.survives = true;
|
||||||
|
fileBrowserProps.loadError = undefined;
|
||||||
|
fileBrowserProps.sourceError = undefined;
|
||||||
|
fileBrowserProps.previewPath = undefined;
|
||||||
|
fileBrowserProps.loadAttempts = 0;
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
sideTaskPanelProps.current = undefined;
|
sideTaskPanelProps.current = undefined;
|
||||||
// The boundary matrix spies on DOMParser.prototype per row; restore it so
|
// The boundary matrix spies on DOMParser.prototype per row; restore it so
|
||||||
@@ -372,12 +451,14 @@ afterEach(() => {
|
|||||||
mockWorkspaceActions.readFileBytes.mockReset();
|
mockWorkspaceActions.readFileBytes.mockReset();
|
||||||
mockWorkspaceActions.readWorkspaceFile.mockReset();
|
mockWorkspaceActions.readWorkspaceFile.mockReset();
|
||||||
mockWorkspaceActions.stat.mockReset();
|
mockWorkspaceActions.stat.mockReset();
|
||||||
|
mockWorkspaceActions.listDirectory.mockReset();
|
||||||
mockWorkspaceActions.listScheduledTasks.mockReset();
|
mockWorkspaceActions.listScheduledTasks.mockReset();
|
||||||
mockWorkspaceActions.updateScheduledTask.mockReset();
|
mockWorkspaceActions.updateScheduledTask.mockReset();
|
||||||
mockWorkspaceActions.deleteScheduledTask.mockReset();
|
mockWorkspaceActions.deleteScheduledTask.mockReset();
|
||||||
mockSecondaryWorkspaceActions.readWorkspaceFile.mockReset();
|
mockSecondaryWorkspaceActions.readWorkspaceFile.mockReset();
|
||||||
mockSecondaryWorkspaceActions.readWorkspaceFileBytes.mockReset();
|
mockSecondaryWorkspaceActions.readWorkspaceFileBytes.mockReset();
|
||||||
mockSecondaryWorkspaceActions.fileStat.mockReset();
|
mockSecondaryWorkspaceActions.fileStat.mockReset();
|
||||||
|
mockSecondaryWorkspaceActions.dirList.mockReset();
|
||||||
mockWorkspace.client.workspaceByCwd.mockClear();
|
mockWorkspace.client.workspaceByCwd.mockClear();
|
||||||
mockWorkspace.capabilities.features = [];
|
mockWorkspace.capabilities.features = [];
|
||||||
latestArtifactWorkspaceTarget = undefined;
|
latestArtifactWorkspaceTarget = undefined;
|
||||||
@@ -567,6 +648,96 @@ describe('ArtifactPanel web previews', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('artifact workspace authority', () => {
|
describe('artifact workspace authority', () => {
|
||||||
|
it.each(['/primary', '/secondary'])(
|
||||||
|
'lists directories through the advertised owner for %s',
|
||||||
|
async (cwd) => {
|
||||||
|
const listing = {
|
||||||
|
kind: 'list',
|
||||||
|
path: 'src',
|
||||||
|
entries: [],
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
mockSecondaryWorkspaceActions.dirList.mockResolvedValue(listing);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<ArtifactWorkspaceTargetProbe revision={0} workspaceCwd={cwd} />,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
latestArtifactWorkspaceTarget?.actions.listDirectory('src'),
|
||||||
|
).resolves.toEqual(listing);
|
||||||
|
expect(mockWorkspace.client.workspaceByCwd).toHaveBeenCalledWith(cwd);
|
||||||
|
expect(mockSecondaryWorkspaceActions.dirList).toHaveBeenCalledWith('src');
|
||||||
|
expect(mockWorkspaceActions.listDirectory).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps the primary directory route only for legacy capabilities', async () => {
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
workspaceCwd: '/primary',
|
||||||
|
features: [],
|
||||||
|
} as unknown as typeof mockWorkspace.capabilities;
|
||||||
|
const listing = { kind: 'list', path: '', entries: [], truncated: false };
|
||||||
|
mockWorkspaceActions.listDirectory.mockResolvedValue(listing);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<ArtifactWorkspaceTargetProbe revision={0} workspaceCwd="/primary" />,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
latestArtifactWorkspaceTarget?.actions.listDirectory(''),
|
||||||
|
).resolves.toEqual(listing);
|
||||||
|
expect(mockWorkspaceActions.listDirectory).toHaveBeenCalledWith('');
|
||||||
|
expect(mockWorkspace.client.workspaceByCwd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a pending directory result and captured actions after owner replacement', async () => {
|
||||||
|
let resolveRead:
|
||||||
|
| ((value: {
|
||||||
|
kind: string;
|
||||||
|
path: string;
|
||||||
|
entries: [];
|
||||||
|
truncated: boolean;
|
||||||
|
}) => void)
|
||||||
|
| undefined;
|
||||||
|
mockSecondaryWorkspaceActions.dirList.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRead = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={0} />));
|
||||||
|
const actions = latestArtifactWorkspaceTarget!.actions;
|
||||||
|
const read = actions.listDirectory('');
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
...mockWorkspace.capabilities,
|
||||||
|
workspaces: mockWorkspace.capabilities.workspaces.map((owner) =>
|
||||||
|
owner.cwd === '/secondary' ? { ...owner, id: 'replacement-id' } : owner,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={1} />));
|
||||||
|
resolveRead?.({ kind: 'list', path: '', entries: [], truncated: false });
|
||||||
|
await expect(read).rejects.toThrow(
|
||||||
|
'Workspace artifact owner is no longer available',
|
||||||
|
);
|
||||||
|
await expect(actions.listDirectory('secret')).rejects.toThrow(
|
||||||
|
'Workspace artifact owner is no longer available',
|
||||||
|
);
|
||||||
|
expect(mockSecondaryWorkspaceActions.dirList).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockWorkspaceActions.listDirectory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps an in-flight read across an equivalent capabilities refresh', async () => {
|
it('keeps an in-flight read across an equivalent capabilities refresh', async () => {
|
||||||
let resolveRead:
|
let resolveRead:
|
||||||
| ((file: { content: string; truncated: boolean }) => void)
|
| ((file: { content: string; truncated: boolean }) => void)
|
||||||
@@ -2192,6 +2363,496 @@ describe('ArtifactPanel add menu', () => {
|
|||||||
expect(onCreateSideTask).not.toHaveBeenCalled();
|
expect(onCreateSideTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the opt-in files action on the empty page and add menu', () => {
|
||||||
|
const onOpenFiles = vi.fn();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
const renderPanel = (tabs: ArtifactPanelTab[]) => (
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={tabs[0]?.id ?? null}
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
items={['files']}
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
act(() => root.render(renderPanel([])));
|
||||||
|
const action = Array.from(container.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.includes('Browse workspace files'),
|
||||||
|
);
|
||||||
|
expect(action).toBeDefined();
|
||||||
|
act(() => action?.click());
|
||||||
|
expect(onOpenFiles).toHaveBeenCalledTimes(1);
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
renderPanel([
|
||||||
|
{ id: 'terminal-one', kind: 'terminal', title: 'Terminal' },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
openAddMenu(container);
|
||||||
|
const menuItem = Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||||
|
).find((item) => item.textContent === 'Files');
|
||||||
|
expect(menuItem).toBeDefined();
|
||||||
|
act(() => menuItem?.click());
|
||||||
|
expect(onOpenFiles).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Files in both entry points when the item is not opted in', () => {
|
||||||
|
const onOpenFiles = vi.fn();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
const renderPanel = (tabs: ArtifactPanelTab[]) => (
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={tabs[0]?.id ?? null}
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
items={['review']}
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
act(() => root.render(renderPanel([])));
|
||||||
|
expect(container.textContent).not.toContain('Browse workspace files');
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
renderPanel([
|
||||||
|
{ id: 'terminal:test', kind: 'terminal', title: 'Terminal' },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
openAddMenu(container);
|
||||||
|
expect(
|
||||||
|
Array.from(document.body.querySelectorAll('[role="menuitem"]')).some(
|
||||||
|
(item) => item.textContent === 'Files',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['browser import', 'source initialization'] as const)(
|
||||||
|
'contains a failed %s inside the files pane and offers recovery',
|
||||||
|
async (failure) => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
if (failure === 'browser import') {
|
||||||
|
fileBrowserProps.loadError = new Error('File tree chunk unavailable');
|
||||||
|
} else {
|
||||||
|
fileBrowserProps.previewPath = 'app.ts';
|
||||||
|
fileBrowserProps.sourceError = new Error('File renderer unavailable');
|
||||||
|
mockWorkspaceActions.stat.mockResolvedValue({
|
||||||
|
type: 'file',
|
||||||
|
sizeBytes: 16,
|
||||||
|
modifiedMs: 1,
|
||||||
|
});
|
||||||
|
mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({
|
||||||
|
content: 'const value = 1;',
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'files:primary',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/primary',
|
||||||
|
workspaceId: 'primary-id',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeTabId="files:primary"
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(container.querySelector('[role="alert"]')).not.toBeNull(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.querySelector('aside[aria-label="Right panel"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('button[aria-label="Close Files"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
const browser = container.querySelector(
|
||||||
|
'[data-testid="workspace-file-browser"]',
|
||||||
|
);
|
||||||
|
if (failure === 'browser import') {
|
||||||
|
expect(container.textContent).toContain('Reload page');
|
||||||
|
expect(container.textContent).not.toContain('Try again');
|
||||||
|
expect(fileBrowserProps.loadAttempts).toBeGreaterThan(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileBrowserProps.loadError = undefined;
|
||||||
|
fileBrowserProps.sourceError = undefined;
|
||||||
|
const retry = Array.from(container.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent === 'Try again',
|
||||||
|
)!;
|
||||||
|
await act(async () => retry.click());
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="recovered-file-source"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBe(browser);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['embedded', 'memory-token'] as const)(
|
||||||
|
'does not reload the host for an import failure (%s)',
|
||||||
|
async (mode) => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
reloadPolicy.standalone = mode !== 'embedded';
|
||||||
|
reloadPolicy.token = 'test-memory-only';
|
||||||
|
reloadPolicy.survives = false;
|
||||||
|
fileBrowserProps.loadError = new Error('Chunk unavailable');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'files:primary',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/primary',
|
||||||
|
workspaceId: 'primary-id',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeTabId="files:primary"
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(container.querySelector('[role="alert"]')).not.toBeNull(),
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain(
|
||||||
|
'Reloading could lose your connection',
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain('Reload page');
|
||||||
|
expect(container.textContent).not.toContain('Try again');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('refuses oversized source files before reading any content bytes', async () => {
|
||||||
|
fileBrowserProps.previewPath = 'huge.log';
|
||||||
|
mockWorkspaceActions.stat.mockResolvedValue({
|
||||||
|
type: 'file',
|
||||||
|
sizeBytes: 90 * 1024 * 1024,
|
||||||
|
modifiedMs: 1,
|
||||||
|
});
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'files:primary',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/primary',
|
||||||
|
workspaceId: 'primary-id',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeTabId="files:primary"
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(container.textContent).toContain('4 MiB source preview limit'),
|
||||||
|
);
|
||||||
|
expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled();
|
||||||
|
expect(mockWorkspaceActions.readFileBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains files panes across tab switches and revokes a hidden workspace owner', async () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
const files: ArtifactPanelTab = {
|
||||||
|
id: 'files:secondary',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/secondary',
|
||||||
|
workspaceId: 'secondary-id',
|
||||||
|
};
|
||||||
|
const terminal: ArtifactPanelTab = {
|
||||||
|
id: 'terminal',
|
||||||
|
kind: 'terminal',
|
||||||
|
title: 'Terminal',
|
||||||
|
};
|
||||||
|
const renderPanel = (activeTabId: string, tabs = [files, terminal]) => (
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
await act(async () => root.render(renderPanel(files.id)));
|
||||||
|
const browser = container.querySelector(
|
||||||
|
'[data-testid="workspace-file-browser"]',
|
||||||
|
);
|
||||||
|
expect(browser).not.toBeNull();
|
||||||
|
expect(browser?.getAttribute('data-cwd')).toBe('/secondary');
|
||||||
|
act(() => root.render(renderPanel(terminal.id)));
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBe(browser);
|
||||||
|
expect(browser?.closest('[hidden]')).not.toBeNull();
|
||||||
|
act(() => root.render(renderPanel(files.id)));
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBe(browser);
|
||||||
|
expect(browser?.closest('[hidden]')).toBeNull();
|
||||||
|
act(() => root.render(renderPanel(terminal.id)));
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
...mockWorkspace.capabilities,
|
||||||
|
workspaces: mockWorkspace.capabilities.workspaces.map((owner) =>
|
||||||
|
owner.id === 'secondary-id' ? { ...owner, trusted: false } : owner,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
act(() => root.render(renderPanel(terminal.id)));
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
'removed',
|
||||||
|
);
|
||||||
|
mockWorkspace.capabilities = {
|
||||||
|
...mockWorkspace.capabilities,
|
||||||
|
workspaces: mockWorkspace.capabilities.workspaces.map((owner) =>
|
||||||
|
owner.id === 'secondary-id' ? { ...owner, trusted: true } : owner,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await act(async () => root.render(renderPanel(files.id)));
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
const restored = container.querySelector(
|
||||||
|
'[data-testid="workspace-file-browser"]',
|
||||||
|
);
|
||||||
|
expect(restored).not.toBe(browser);
|
||||||
|
expect(restored?.getAttribute('data-cwd')).toBe('/secondary');
|
||||||
|
act(() => root.render(renderPanel(terminal.id, [terminal])));
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, 'old-owner'])(
|
||||||
|
'allows reopening files when a persisted owner is stale (%s)',
|
||||||
|
async (workspaceId) => {
|
||||||
|
const onOpenFiles = vi.fn();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'files:old',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/primary',
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeTabId="files:old"
|
||||||
|
workspaceCwd="/primary"
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
items={['files']}
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBeNull();
|
||||||
|
openAddMenu(container);
|
||||||
|
const item = Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||||
|
).find((item) => item.textContent === 'Files');
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
act(() => item?.click());
|
||||||
|
expect(onOpenFiles).toHaveBeenCalledOnce();
|
||||||
|
expect(mockSecondaryWorkspaceActions.dirList).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['/primary', '/secondary'])(
|
||||||
|
'only exposes file references for the active workspace (%s)',
|
||||||
|
async (workspaceCwd) => {
|
||||||
|
const onReferenceFile = vi.fn();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
fileBrowserProps.current = undefined;
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'files:secondary',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/secondary',
|
||||||
|
workspaceId: 'secondary-id',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeTabId="files:secondary"
|
||||||
|
workspaceCwd={workspaceCwd}
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
items={['files']}
|
||||||
|
onReferenceFile={onReferenceFile}
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(fileBrowserProps.current).toBeDefined();
|
||||||
|
if (workspaceCwd === '/secondary') {
|
||||||
|
fileBrowserProps.current?.onReferenceFile?.('src/app.ts');
|
||||||
|
expect(onReferenceFile).toHaveBeenCalledWith('src/app.ts');
|
||||||
|
} else {
|
||||||
|
expect(fileBrowserProps.current?.onReferenceFile).toBeUndefined();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects a restored files tab with a different workspace ID before mounting the tree', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mounted.push({ root, container });
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<I18nProvider language="en">
|
||||||
|
<ArtifactPanel
|
||||||
|
artifacts={[]}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'files:old-owner',
|
||||||
|
kind: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
workspaceCwd: '/primary',
|
||||||
|
workspaceId: 'old-owner',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeTabId="files:old-owner"
|
||||||
|
reviewChanges={[]}
|
||||||
|
selectedReviewPath={null}
|
||||||
|
items={['files']}
|
||||||
|
onSelectTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onOpenFilePreview={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
'removed',
|
||||||
|
);
|
||||||
|
expect(mockSecondaryWorkspaceActions.dirList).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="workspace-file-browser"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('opens the latest review from the empty page', () => {
|
it('opens the latest review from the empty page', () => {
|
||||||
const onOpenLatestReview = vi.fn();
|
const onOpenLatestReview = vi.fn();
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useDaemonTargetOptional } from '../../config/daemon-target';
|
||||||
|
import { hasReloadSurvivableDaemonToken } from '../../config/daemon';
|
||||||
import type {
|
import type {
|
||||||
DaemonSessionArtifact,
|
DaemonSessionArtifact,
|
||||||
SessionSource,
|
SessionSource,
|
||||||
@@ -9,6 +11,7 @@ import type { ACPToolCall, TodoItem } from '../../adapters/types';
|
|||||||
import type { WebShellRightPanelItem } from '../../customization';
|
import type { WebShellRightPanelItem } from '../../customization';
|
||||||
import {
|
import {
|
||||||
useConnection,
|
useConnection,
|
||||||
|
useWorkspace,
|
||||||
type DaemonSessionOwnerSnapshot,
|
type DaemonSessionOwnerSnapshot,
|
||||||
type DaemonSessionActions,
|
type DaemonSessionActions,
|
||||||
type DaemonScheduledTask,
|
type DaemonScheduledTask,
|
||||||
@@ -24,6 +27,7 @@ import {
|
|||||||
ExpandIcon,
|
ExpandIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
FolderIcon as FilesIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
@@ -38,6 +42,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Skeleton } from '../ui/skeleton';
|
import { Skeleton } from '../ui/skeleton';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
import { ErrorBoundary } from '../ErrorBoundary';
|
||||||
|
import { RootErrorFallback } from '../RootErrorFallback';
|
||||||
import {
|
import {
|
||||||
lazy,
|
lazy,
|
||||||
Suspense,
|
Suspense,
|
||||||
@@ -60,7 +66,6 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime';
|
|||||||
import { normalizeTextMediaType } from '../../utils/imageIngestion';
|
import { normalizeTextMediaType } from '../../utils/imageIngestion';
|
||||||
import { DialogShell } from '../dialogs/DialogShell';
|
import { DialogShell } from '../dialogs/DialogShell';
|
||||||
import { FileTypeIcon } from '../FileTypeIcon';
|
import { FileTypeIcon } from '../FileTypeIcon';
|
||||||
import { ErrorBoundary } from '../ErrorBoundary';
|
|
||||||
import { isSafeHref, Markdown } from '../messages/Markdown';
|
import { isSafeHref, Markdown } from '../messages/Markdown';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -125,6 +130,7 @@ import type { ContextUsageControls } from '../../hooks/useContextUsageControls';
|
|||||||
import { ContextUsagePanel } from './ContextUsagePanel';
|
import { ContextUsagePanel } from './ContextUsagePanel';
|
||||||
import {
|
import {
|
||||||
useArtifactWorkspaceTarget,
|
useArtifactWorkspaceTarget,
|
||||||
|
resolveArtifactWorkspaceOwner,
|
||||||
type ArtifactWorkspaceActions,
|
type ArtifactWorkspaceActions,
|
||||||
} from './useArtifactWorkspaceTarget';
|
} from './useArtifactWorkspaceTarget';
|
||||||
import {
|
import {
|
||||||
@@ -132,6 +138,20 @@ import {
|
|||||||
ShellTaskDetail,
|
ShellTaskDetail,
|
||||||
} from '../messages/TasksStatusMessage';
|
} from '../messages/TasksStatusMessage';
|
||||||
|
|
||||||
|
class FilePaneImportError extends Error {}
|
||||||
|
|
||||||
|
function fileImportFailed(error: unknown): never {
|
||||||
|
throw new FilePaneImportError(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWorkspaceFileBrowser = () =>
|
||||||
|
import('./WorkspaceFileBrowser')
|
||||||
|
.then((module) => ({ default: module.WorkspaceFileBrowser }))
|
||||||
|
.catch(fileImportFailed);
|
||||||
|
const loadPierreFilePreview = () =>
|
||||||
|
import('./PierreFilePreview').catch(fileImportFailed);
|
||||||
const SpreadsheetPreview = lazy(() => import('./SpreadsheetPreview'));
|
const SpreadsheetPreview = lazy(() => import('./SpreadsheetPreview'));
|
||||||
|
|
||||||
const MAX_REVIEW_SIDE_BY_SIDE_WIDTH = 700;
|
const MAX_REVIEW_SIDE_BY_SIDE_WIDTH = 700;
|
||||||
@@ -160,6 +180,13 @@ export type ImageTabSource = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ArtifactPanelTab =
|
export type ArtifactPanelTab =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: 'files';
|
||||||
|
title: string;
|
||||||
|
workspaceCwd: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
}
|
||||||
| (WebPreviewState & {
|
| (WebPreviewState & {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'web_preview';
|
kind: 'web_preview';
|
||||||
@@ -413,7 +440,7 @@ interface ArtifactPanelProps {
|
|||||||
onSelectTab: (tabId: string) => void;
|
onSelectTab: (tabId: string) => void;
|
||||||
onCloseTab: (tabId: string) => void;
|
onCloseTab: (tabId: string) => void;
|
||||||
onOpenFilePreview: (
|
onOpenFilePreview: (
|
||||||
change: TurnOutputFileChange,
|
change: TurnOutputFileChange | string,
|
||||||
workspaceCwd?: string,
|
workspaceCwd?: string,
|
||||||
workspaceId?: string,
|
workspaceId?: string,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -430,6 +457,8 @@ interface ArtifactPanelProps {
|
|||||||
* than for any trajectory tab at all.
|
* than for any trajectory tab at all.
|
||||||
*/
|
*/
|
||||||
trajectoryTabId?: string;
|
trajectoryTabId?: string;
|
||||||
|
onOpenFiles?: () => void;
|
||||||
|
onReferenceFile?: (path: string) => void;
|
||||||
onOpenWebPreview?: () => void;
|
onOpenWebPreview?: () => void;
|
||||||
onWebPreviewChange?: (tabId: string, state: WebPreviewState) => void;
|
onWebPreviewChange?: (tabId: string, state: WebPreviewState) => void;
|
||||||
items?: readonly WebShellRightPanelItem[];
|
items?: readonly WebShellRightPanelItem[];
|
||||||
@@ -489,6 +518,113 @@ interface ArtifactPanelProps {
|
|||||||
onToggleFullscreen?: () => void;
|
onToggleFullscreen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceFilesPane({
|
||||||
|
tab,
|
||||||
|
workspaceCwd,
|
||||||
|
onOpenFilePreview,
|
||||||
|
onReferenceFile,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
tab: Extract<ArtifactPanelTab, { kind: 'files' }>;
|
||||||
|
} & Pick<
|
||||||
|
ArtifactPanelProps,
|
||||||
|
'workspaceCwd' | 'onOpenFilePreview' | 'onReferenceFile'
|
||||||
|
>) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
|
const daemonTarget = useDaemonTargetOptional();
|
||||||
|
const WorkspaceFileBrowser = useMemo(
|
||||||
|
() => lazy(loadWorkspaceFileBrowser),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const PierreFilePreview = useMemo(() => lazy(loadPierreFilePreview), []);
|
||||||
|
const target = useArtifactWorkspaceTarget(tab.workspaceCwd);
|
||||||
|
const actions =
|
||||||
|
target?.workspaceId === tab.workspaceId ? target?.actions : undefined;
|
||||||
|
if (!actions) {
|
||||||
|
return (
|
||||||
|
<div className={styles.empty} role="alert">
|
||||||
|
{t('workspace.notFoundDescription')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const failure = (error: Error, reset: () => void) => {
|
||||||
|
// Browsers cache rejected module imports too; only a page reload clears
|
||||||
|
// that cache. Renderer errors can remount locally without losing the tree.
|
||||||
|
const reload = error instanceof FilePaneImportError;
|
||||||
|
const canReload = () =>
|
||||||
|
Boolean(
|
||||||
|
daemonTarget &&
|
||||||
|
(!daemonTarget.activeToken || hasReloadSurvivableDaemonToken()),
|
||||||
|
);
|
||||||
|
if (reload && !canReload())
|
||||||
|
return (
|
||||||
|
<div role="alert" className={styles.empty}>
|
||||||
|
<p>{t('files.loadFailed')}</p>
|
||||||
|
<p>{t('files.reloadUnavailable')}</p>
|
||||||
|
<Button onClick={onClose}>{t('files.close')}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<RootErrorFallback
|
||||||
|
error={error}
|
||||||
|
language={language}
|
||||||
|
retryMode={reload ? 'reload' : 'reset'}
|
||||||
|
onRetry={
|
||||||
|
reload
|
||||||
|
? () => {
|
||||||
|
if (canReload()) window.location.reload();
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
: reset
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ErrorBoundary label="workspace file browser" fallback={failure}>
|
||||||
|
<Suspense
|
||||||
|
fallback={<div className={styles.empty}>{t('common.loading')}</div>}
|
||||||
|
>
|
||||||
|
<WorkspaceFileBrowser
|
||||||
|
key={`${tab.workspaceCwd}:${tab.workspaceId ?? ''}`}
|
||||||
|
workspaceCwd={tab.workspaceCwd}
|
||||||
|
actions={actions}
|
||||||
|
onOpenPreview={(path) =>
|
||||||
|
onOpenFilePreview(path, tab.workspaceCwd, tab.workspaceId)
|
||||||
|
}
|
||||||
|
onReferenceFile={
|
||||||
|
tab.workspaceCwd === workspaceCwd ? onReferenceFile : undefined
|
||||||
|
}
|
||||||
|
renderPreview={(path, source, version) => (
|
||||||
|
<WorkspaceFilePreview
|
||||||
|
key={`${path}:${source}:${version}`}
|
||||||
|
workspacePath={path}
|
||||||
|
workspaceActions={actions}
|
||||||
|
previewKind={source ? 'source' : undefined}
|
||||||
|
sourceRenderer={(content) => (
|
||||||
|
<ErrorBoundary
|
||||||
|
label="workspace file source"
|
||||||
|
resetKeys={[path]}
|
||||||
|
fallback={failure}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className={styles.empty}>{t('common.loading')}</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PierreFilePreview path={path} content={content} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ArtifactPanel({
|
export function ArtifactPanel({
|
||||||
contextUsageControls,
|
contextUsageControls,
|
||||||
artifacts,
|
artifacts,
|
||||||
@@ -510,6 +646,8 @@ export function ArtifactPanel({
|
|||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
onOpenTrajectory,
|
onOpenTrajectory,
|
||||||
trajectoryTabId,
|
trajectoryTabId,
|
||||||
|
onOpenFiles,
|
||||||
|
onReferenceFile,
|
||||||
onOpenWebPreview,
|
onOpenWebPreview,
|
||||||
onWebPreviewChange,
|
onWebPreviewChange,
|
||||||
items = DEFAULT_RIGHT_PANEL_ITEMS,
|
items = DEFAULT_RIGHT_PANEL_ITEMS,
|
||||||
@@ -541,6 +679,11 @@ export function ArtifactPanel({
|
|||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
}: ArtifactPanelProps) {
|
}: ArtifactPanelProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const filesOwner = resolveArtifactWorkspaceOwner(
|
||||||
|
workspace.capabilities,
|
||||||
|
workspaceCwd,
|
||||||
|
);
|
||||||
const [sideTaskMenuOpen, setSideTaskMenuOpen] = useState(false);
|
const [sideTaskMenuOpen, setSideTaskMenuOpen] = useState(false);
|
||||||
const [previewAttachmentId, setPreviewAttachmentId] = useState<string>();
|
const [previewAttachmentId, setPreviewAttachmentId] = useState<string>();
|
||||||
const sideTaskMenuCloseTimerRef = useRef<ReturnType<
|
const sideTaskMenuCloseTimerRef = useRef<ReturnType<
|
||||||
@@ -606,9 +749,19 @@ export function ArtifactPanel({
|
|||||||
items.includes('trajectory') &&
|
items.includes('trajectory') &&
|
||||||
Boolean(onOpenTrajectory) &&
|
Boolean(onOpenTrajectory) &&
|
||||||
!tabs.some((tab) => tab.id === trajectoryTabId);
|
!tabs.some((tab) => tab.id === trajectoryTabId);
|
||||||
|
const showFilesMenuItem =
|
||||||
|
items.includes('files') &&
|
||||||
|
Boolean(onOpenFiles) &&
|
||||||
|
!tabs.some(
|
||||||
|
(tab) =>
|
||||||
|
tab.kind === 'files' &&
|
||||||
|
tab.workspaceCwd === workspaceCwd &&
|
||||||
|
tab.workspaceId === filesOwner?.id,
|
||||||
|
);
|
||||||
const showAddMenu =
|
const showAddMenu =
|
||||||
Boolean(activeTab) &&
|
Boolean(activeTab) &&
|
||||||
(showReviewMenuItem ||
|
(showFilesMenuItem ||
|
||||||
|
showReviewMenuItem ||
|
||||||
showSideTaskMenuItems ||
|
showSideTaskMenuItems ||
|
||||||
showTerminalMenuItem ||
|
showTerminalMenuItem ||
|
||||||
showWebPreviewMenuItem ||
|
showWebPreviewMenuItem ||
|
||||||
@@ -677,6 +830,8 @@ export function ArtifactPanel({
|
|||||||
>
|
>
|
||||||
{getArtifactPanelTabKind(tab) === 'review' ? (
|
{getArtifactPanelTabKind(tab) === 'review' ? (
|
||||||
<TabReviewIcon />
|
<TabReviewIcon />
|
||||||
|
) : tab.kind === 'files' ? (
|
||||||
|
<FilesIcon className={styles.tabIconSvg} />
|
||||||
) : tab.kind === 'workflow' ? (
|
) : tab.kind === 'workflow' ? (
|
||||||
<NetworkIcon
|
<NetworkIcon
|
||||||
className={styles.tabIconSvg}
|
className={styles.tabIconSvg}
|
||||||
@@ -788,6 +943,14 @@ export function ArtifactPanel({
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-64">
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
{showFilesMenuItem && (
|
||||||
|
<DropdownMenuItem onSelect={onOpenFiles}>
|
||||||
|
<FilesIcon />
|
||||||
|
<span className={styles.sideTaskListTitle}>
|
||||||
|
{t('files.title')}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{showReviewMenuItem && (
|
{showReviewMenuItem && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!latestReviewAvailable || !onOpenLatestReview}
|
disabled={!latestReviewAvailable || !onOpenLatestReview}
|
||||||
@@ -890,7 +1053,9 @@ export function ArtifactPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${styles.body} ${
|
className={`${styles.body} ${
|
||||||
activeTab?.kind === 'side_task' ? styles.bodySideTask : ''
|
activeTab?.kind === 'side_task' || activeTab?.kind === 'files'
|
||||||
|
? styles.bodySideTask
|
||||||
|
: ''
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
>
|
>
|
||||||
{tabs
|
{tabs
|
||||||
@@ -907,6 +1072,23 @@ export function ArtifactPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{tabs
|
||||||
|
.filter((tab) => tab.kind === 'files')
|
||||||
|
.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className="h-full"
|
||||||
|
hidden={tab.id !== activeTab?.id}
|
||||||
|
>
|
||||||
|
<WorkspaceFilesPane
|
||||||
|
onClose={() => onCloseTab(tab.id)}
|
||||||
|
tab={tab}
|
||||||
|
workspaceCwd={workspaceCwd}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
onReferenceFile={onReferenceFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{tabs
|
{tabs
|
||||||
.filter((tab) => tab.kind === 'terminal')
|
.filter((tab) => tab.kind === 'terminal')
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
@@ -966,11 +1148,35 @@ export function ArtifactPanel({
|
|||||||
<Skeleton className="h-4 w-3/5" />
|
<Skeleton className="h-4 w-3/5" />
|
||||||
</div>
|
</div>
|
||||||
) : activeTab?.kind === 'terminal' ||
|
) : activeTab?.kind === 'terminal' ||
|
||||||
activeTab?.kind === 'web_preview' ? null : !activeTab ? (
|
activeTab?.kind === 'web_preview' ||
|
||||||
|
activeTab?.kind === 'files' ? null : !activeTab ? (
|
||||||
<div
|
<div
|
||||||
className={styles.emptyActions}
|
className={styles.emptyActions}
|
||||||
data-testid="right-panel-empty-actions"
|
data-testid="right-panel-empty-actions"
|
||||||
>
|
>
|
||||||
|
{items.includes('files') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.emptyAction}
|
||||||
|
disabled={!onOpenFiles}
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
>
|
||||||
|
<span className={styles.emptyActionIcon} aria-hidden="true">
|
||||||
|
<FilesIcon />
|
||||||
|
</span>
|
||||||
|
<span className={styles.emptyActionTitle}>
|
||||||
|
{t('files.title')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.emptyActionHint}>
|
||||||
|
{t('files.description')}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={styles.emptyActionChevron}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{items.includes('review') && (
|
{items.includes('review') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3457,7 +3663,9 @@ function WorkspaceFilePreview({
|
|||||||
previewKind,
|
previewKind,
|
||||||
previewOnly,
|
previewOnly,
|
||||||
onLoadError,
|
onLoadError,
|
||||||
|
sourceRenderer,
|
||||||
}: {
|
}: {
|
||||||
|
sourceRenderer?: (content: string) => ReactNode;
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
artifactVersion?: string;
|
artifactVersion?: string;
|
||||||
workspaceActions: ArtifactWorkspaceActions;
|
workspaceActions: ArtifactWorkspaceActions;
|
||||||
@@ -3586,6 +3794,7 @@ function WorkspaceFilePreview({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<FileArtifactPreview
|
<FileArtifactPreview
|
||||||
|
sourceRenderer={sourceRenderer}
|
||||||
workspacePath={workspacePath}
|
workspacePath={workspacePath}
|
||||||
artifactVersion={artifactVersion}
|
artifactVersion={artifactVersion}
|
||||||
workspaceActions={workspaceActions}
|
workspaceActions={workspaceActions}
|
||||||
@@ -3822,6 +4031,7 @@ function useWorkspaceFileContent({
|
|||||||
previewContent,
|
previewContent,
|
||||||
previewOnly,
|
previewOnly,
|
||||||
onLoadError,
|
onLoadError,
|
||||||
|
maxPreviewBytes,
|
||||||
}: {
|
}: {
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
artifactVersion?: string;
|
artifactVersion?: string;
|
||||||
@@ -3829,7 +4039,9 @@ function useWorkspaceFileContent({
|
|||||||
previewContent?: string;
|
previewContent?: string;
|
||||||
previewOnly?: boolean;
|
previewOnly?: boolean;
|
||||||
onLoadError?: (error: string) => void;
|
onLoadError?: (error: string) => void;
|
||||||
|
maxPreviewBytes?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [content, setContent] = useState<string | null>(previewContent ?? null);
|
const [content, setContent] = useState<string | null>(previewContent ?? null);
|
||||||
const [sizeBytes, setSizeBytes] = useState<number>();
|
const [sizeBytes, setSizeBytes] = useState<number>();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -3847,6 +4059,8 @@ function useWorkspaceFileContent({
|
|||||||
if (stat.type === 'directory') {
|
if (stat.type === 'directory') {
|
||||||
throw new Error('Directories cannot be opened as artifacts.');
|
throw new Error('Directories cannot be opened as artifacts.');
|
||||||
}
|
}
|
||||||
|
if (maxPreviewBytes !== undefined && stat.sizeBytes > maxPreviewBytes)
|
||||||
|
throw new Error(t('files.sourceTooLarge'));
|
||||||
return workspaceActions.readWorkspaceFile(workspacePath, {
|
return workspaceActions.readWorkspaceFile(workspacePath, {
|
||||||
maxBytes: 256 * 1024,
|
maxBytes: 256 * 1024,
|
||||||
});
|
});
|
||||||
@@ -3860,6 +4074,7 @@ function useWorkspaceFileContent({
|
|||||||
{
|
{
|
||||||
statFile: (filePath) => workspaceActions.stat(filePath),
|
statFile: (filePath) => workspaceActions.stat(filePath),
|
||||||
isCancelled: () => cancelled,
|
isCancelled: () => cancelled,
|
||||||
|
maxBytes: maxPreviewBytes,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -3890,6 +4105,8 @@ function useWorkspaceFileContent({
|
|||||||
onLoadError,
|
onLoadError,
|
||||||
workspaceActions,
|
workspaceActions,
|
||||||
workspacePath,
|
workspacePath,
|
||||||
|
maxPreviewBytes,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { content, error, sizeBytes };
|
return { content, error, sizeBytes };
|
||||||
@@ -3996,7 +4213,9 @@ function FileArtifactPreview({
|
|||||||
previewContent,
|
previewContent,
|
||||||
previewOnly,
|
previewOnly,
|
||||||
onLoadError,
|
onLoadError,
|
||||||
|
sourceRenderer,
|
||||||
}: {
|
}: {
|
||||||
|
sourceRenderer?: (content: string) => ReactNode;
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
artifactVersion?: string;
|
artifactVersion?: string;
|
||||||
workspaceActions: ArtifactWorkspaceActions;
|
workspaceActions: ArtifactWorkspaceActions;
|
||||||
@@ -4013,6 +4232,7 @@ function FileArtifactPreview({
|
|||||||
previewContent,
|
previewContent,
|
||||||
previewOnly,
|
previewOnly,
|
||||||
onLoadError,
|
onLoadError,
|
||||||
|
maxPreviewBytes: sourceRenderer ? 4 * 1024 * 1024 : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -4037,12 +4257,14 @@ function FileArtifactPreview({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return () => view.destroy();
|
return () => view.destroy();
|
||||||
}, [content]);
|
}, [content, sourceRenderer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.filePreviewWrap}>
|
<div className={styles.filePreviewWrap}>
|
||||||
{content === null ? (
|
{content === null ? (
|
||||||
!error && <div className={styles.empty}>Loading file...</div>
|
!error && <div className={styles.empty}>Loading file...</div>
|
||||||
|
) : sourceRenderer ? (
|
||||||
|
<div className={styles.codeMirrorFile}>{sourceRenderer(content)}</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={hostRef} className={styles.codeMirrorFile} />
|
<div ref={hostRef} className={styles.codeMirrorFile} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
import { ThemeProvider } from '../../themeContext';
|
||||||
|
import PierreFilePreview from './PierreFilePreview';
|
||||||
|
import { ErrorBoundary } from '../ErrorBoundary';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
render: vi.fn(),
|
||||||
|
cleanUp: vi.fn(),
|
||||||
|
setThemeType: vi.fn(),
|
||||||
|
virtualizerCleanUp: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@pierre/diffs', () => ({
|
||||||
|
DIFFS_TAG_NAME: 'diffs-container',
|
||||||
|
Virtualizer: class {
|
||||||
|
setup() {}
|
||||||
|
cleanUp = mocks.virtualizerCleanUp;
|
||||||
|
},
|
||||||
|
VirtualizedFile: class {
|
||||||
|
constructor(options: unknown) {
|
||||||
|
mocks.create(options);
|
||||||
|
}
|
||||||
|
render = mocks.render;
|
||||||
|
cleanUp = mocks.cleanUp;
|
||||||
|
setThemeType = mocks.setThemeType;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('cleans up a failed renderer initialization before a local retry', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mocks.cleanUp.mockClear();
|
||||||
|
mocks.virtualizerCleanUp.mockClear();
|
||||||
|
mocks.render.mockImplementationOnce(() => {
|
||||||
|
throw new Error('Renderer failed');
|
||||||
|
});
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
const render = (attempt: number) => (
|
||||||
|
<ErrorBoundary
|
||||||
|
resetKeys={[attempt]}
|
||||||
|
fallback={<div role="alert">Preview failed</div>}
|
||||||
|
>
|
||||||
|
<PierreFilePreview path="app.ts" content="const value = 1;" />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(render(0)));
|
||||||
|
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||||
|
expect(mocks.cleanUp).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.virtualizerCleanUp).toHaveBeenCalledOnce();
|
||||||
|
await act(async () => root.render(render(1)));
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="pierre-file-preview"]')?.childNodes
|
||||||
|
.length,
|
||||||
|
).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
log.mockRestore();
|
||||||
|
mocks.create.mockClear();
|
||||||
|
mocks.render.mockClear();
|
||||||
|
mocks.cleanUp.mockClear();
|
||||||
|
mocks.setThemeType.mockClear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the theme without replacing the virtualized file or scroll position', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<ThemeProvider value="light">
|
||||||
|
<PierreFilePreview path="app.ts" content="const value = 1;" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const host = container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="pierre-file-preview"]',
|
||||||
|
)!;
|
||||||
|
const file = host.firstChild;
|
||||||
|
host.scrollTop = 123;
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<ThemeProvider value="dark">
|
||||||
|
<PierreFilePreview path="app.ts" content="const value = 1;" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mocks.create).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.render).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.cleanUp).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.setThemeType.mock.calls).toEqual([['dark']]);
|
||||||
|
expect(mocks.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
themeType: 'light',
|
||||||
|
disableErrorHandling: true,
|
||||||
|
tokenizeMaxLength: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(host.firstChild).toBe(file);
|
||||||
|
expect(host.scrollTop).toBe(123);
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
expect(mocks.cleanUp).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
if (container.hasChildNodes()) act(() => root.unmount());
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recreates changed files with the current shell theme and pins the character fallback', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
mocks.create.mockClear();
|
||||||
|
mocks.render.mockClear();
|
||||||
|
mocks.cleanUp.mockClear();
|
||||||
|
const root = createRoot(document.createElement('div'));
|
||||||
|
const render = (path: string, content: string) => (
|
||||||
|
<ThemeProvider value="dark">
|
||||||
|
<PierreFilePreview path={path} content={content} />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(render('first.ts', 'small')));
|
||||||
|
expect(mocks.render).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
file: expect.objectContaining({ name: 'first.ts', lang: undefined }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await act(async () =>
|
||||||
|
root.render(render('second.ts', 'x'.repeat(1024 * 1024 + 1))),
|
||||||
|
);
|
||||||
|
expect(mocks.create).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.cleanUp).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.create).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
themeType: 'dark',
|
||||||
|
disableErrorHandling: true,
|
||||||
|
tokenizeMaxLength: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.render).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
file: expect.objectContaining({ name: 'second.ts', lang: 'text' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await act(async () => root.render(render('second.ts', 'changed')));
|
||||||
|
expect(mocks.create).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mocks.render).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
file: expect.objectContaining({
|
||||||
|
name: 'second.ts',
|
||||||
|
contents: 'changed',
|
||||||
|
lang: undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { DIFFS_TAG_NAME, VirtualizedFile, Virtualizer } from '@pierre/diffs';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTheme } from '../../themeContext';
|
||||||
|
|
||||||
|
export default function PierreFilePreview({
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const themeRef = useRef(theme);
|
||||||
|
themeRef.current = theme;
|
||||||
|
const appliedThemeRef = useRef(theme);
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<VirtualizedFile | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return undefined;
|
||||||
|
const contentContainer = document.createElement('div');
|
||||||
|
const fileContainer = document.createElement(DIFFS_TAG_NAME);
|
||||||
|
// Pierre anchors zero-height files at their bottom during async highlighting.
|
||||||
|
fileContainer.style.display = 'block';
|
||||||
|
fileContainer.style.minHeight = '1px';
|
||||||
|
contentContainer.appendChild(fileContainer);
|
||||||
|
host.replaceChildren(contentContainer);
|
||||||
|
const virtualizer = new Virtualizer();
|
||||||
|
let view: VirtualizedFile | undefined;
|
||||||
|
const cleanUp = () => {
|
||||||
|
viewRef.current = undefined;
|
||||||
|
view?.cleanUp();
|
||||||
|
virtualizer.cleanUp();
|
||||||
|
host.replaceChildren();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
virtualizer.setup(host, contentContainer);
|
||||||
|
view = new VirtualizedFile(
|
||||||
|
{
|
||||||
|
theme: { light: 'github-light', dark: 'github-dark' },
|
||||||
|
themeType: themeRef.current,
|
||||||
|
disableErrorHandling: true,
|
||||||
|
preferredHighlighter: 'shiki-js',
|
||||||
|
overflow: 'wrap',
|
||||||
|
disableFileHeader: true,
|
||||||
|
enableLineSelection: true,
|
||||||
|
tokenizeMaxLength: 10_000,
|
||||||
|
},
|
||||||
|
virtualizer,
|
||||||
|
);
|
||||||
|
viewRef.current = view;
|
||||||
|
appliedThemeRef.current = themeRef.current;
|
||||||
|
view.render({
|
||||||
|
file: {
|
||||||
|
name: path,
|
||||||
|
contents: content,
|
||||||
|
lang: content.length > 1024 * 1024 ? 'text' : undefined,
|
||||||
|
},
|
||||||
|
fileContainer,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
cleanUp();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return cleanUp;
|
||||||
|
}, [path, content]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (appliedThemeRef.current !== theme) {
|
||||||
|
viewRef.current?.setThemeType(theme);
|
||||||
|
appliedThemeRef.current = theme;
|
||||||
|
}
|
||||||
|
}, [theme, path, content]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className="h-full overflow-auto"
|
||||||
|
data-testid="pierre-file-preview"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const mock = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (original) => ({
|
vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (original) => ({
|
||||||
...(await original<Record<string, unknown>>()),
|
...(await original<Record<string, unknown>>()),
|
||||||
|
useWorkspace: () => ({ capabilities: undefined }),
|
||||||
useConnection: () => ({
|
useConnection: () => ({
|
||||||
...mock.connection,
|
...mock.connection,
|
||||||
workspaceCwd: mock.standalone ? undefined : mock.connection.workspaceCwd,
|
workspaceCwd: mock.standalone ? undefined : mock.connection.workspaceCwd,
|
||||||
@@ -37,7 +38,8 @@ const target = {
|
|||||||
workspaceId: 'owner-a',
|
workspaceId: 'owner-a',
|
||||||
actions: mock.actions,
|
actions: mock.actions,
|
||||||
};
|
};
|
||||||
vi.mock('./useArtifactWorkspaceTarget', () => ({
|
vi.mock('./useArtifactWorkspaceTarget', async (original) => ({
|
||||||
|
...(await original<typeof import('./useArtifactWorkspaceTarget')>()),
|
||||||
useArtifactWorkspaceTarget: (cwd: string) =>
|
useArtifactWorkspaceTarget: (cwd: string) =>
|
||||||
mock.trusted && cwd ? { ...target } : undefined,
|
mock.trusted && cwd ? { ...target } : undefined,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
.browser {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitHandle {
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitHandle[hidden],
|
||||||
|
.sidebar[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search > svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search > input {
|
||||||
|
height: 34px;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
--trees-bg-override: var(--background);
|
||||||
|
--trees-fg-override: var(--foreground);
|
||||||
|
--trees-fg-muted-override: var(--muted-foreground);
|
||||||
|
--trees-bg-muted-override: var(--muted);
|
||||||
|
--trees-selected-bg-override: var(--accent);
|
||||||
|
--trees-selected-fg-override: var(--accent-foreground);
|
||||||
|
--trees-focus-ring-color-override: var(--ring);
|
||||||
|
--trees-border-color-override: var(--border);
|
||||||
|
--trees-font-family-override: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notices {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMatches {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import { safeDisplayText } from '../../hooks/useAtMentionSources';
|
||||||
|
import { usePanelRef } from 'react-resizable-panels';
|
||||||
|
import { FileTree, type ContextMenuOpenContext } from '@pierre/trees';
|
||||||
|
import {
|
||||||
|
Code2Icon,
|
||||||
|
EyeIcon,
|
||||||
|
FolderIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
import { useTheme } from '../../themeContext';
|
||||||
|
import type { DaemonDirectoryEntry } from '@qwen-code/web-shell/daemon-react-sdk';
|
||||||
|
import type { ArtifactWorkspaceActions } from './useArtifactWorkspaceTarget';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from '../ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
downloadWorkspaceFile,
|
||||||
|
getImageMimeTypeFromPath,
|
||||||
|
isExcelFile,
|
||||||
|
} from './artifactUtils';
|
||||||
|
import { writeClipboardText } from '../../utils/clipboard';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from '../ui/resizable';
|
||||||
|
import styles from './WorkspaceFileBrowser.module.css';
|
||||||
|
|
||||||
|
interface DirectoryState {
|
||||||
|
path: string;
|
||||||
|
error?: string;
|
||||||
|
empty?: boolean;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFileBrowserProps {
|
||||||
|
workspaceCwd: string;
|
||||||
|
actions: ArtifactWorkspaceActions;
|
||||||
|
onOpenPreview: (path: string) => void;
|
||||||
|
onReferenceFile?: (path: string) => void;
|
||||||
|
renderPreview: (path: string, source: boolean, version: number) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceFileBrowser({
|
||||||
|
workspaceCwd,
|
||||||
|
actions,
|
||||||
|
renderPreview,
|
||||||
|
onOpenPreview,
|
||||||
|
onReferenceFile,
|
||||||
|
}: WorkspaceFileBrowserProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const theme = useTheme();
|
||||||
|
const treePanelRef = usePanelRef();
|
||||||
|
const contentPanelRef = usePanelRef();
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const treeLabelRef = useRef(t('files.title'));
|
||||||
|
treeLabelRef.current = t('files.title');
|
||||||
|
const treeRef = useRef<FileTree | undefined>(undefined);
|
||||||
|
const retryRef = useRef<((path: string) => void) | undefined>(undefined);
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string>();
|
||||||
|
const hasPreview = selectedPath !== undefined;
|
||||||
|
const [source, setSource] = useState(false);
|
||||||
|
const [treeVisible, setTreeVisible] = useState(true);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [noMatches, setNoMatches] = useState(false);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const [directories, setDirectories] = useState<DirectoryState[]>([]);
|
||||||
|
const [menu, setMenu] = useState<{
|
||||||
|
path: string;
|
||||||
|
context: ContextMenuOpenContext;
|
||||||
|
}>();
|
||||||
|
const [actionError, setActionError] = useState<string>();
|
||||||
|
const lifetimeRef = useRef<object | undefined>(undefined);
|
||||||
|
// Pierre restores focus synchronously and can reenter Radix dismissal.
|
||||||
|
const closingMenuRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasPreview) return undefined;
|
||||||
|
// Wait for the group to register the nonzero preview constraints.
|
||||||
|
const frame = requestAnimationFrame(() =>
|
||||||
|
contentPanelRef.current?.resize('70%'),
|
||||||
|
);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [hasPreview, contentPanelRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return undefined;
|
||||||
|
let disposed = false;
|
||||||
|
const lifetime = {};
|
||||||
|
lifetimeRef.current = lifetime;
|
||||||
|
setMenu(undefined);
|
||||||
|
const entries = new Map<string, DaemonDirectoryEntry>();
|
||||||
|
const cache = new Map<string, DirectoryState>();
|
||||||
|
const tree = new FileTree({
|
||||||
|
paths: [],
|
||||||
|
initialExpansion: 'closed',
|
||||||
|
flattenEmptyDirectories: false,
|
||||||
|
icons: 'complete',
|
||||||
|
unsafeCSS: `
|
||||||
|
[data-file-tree-virtualized-scroll] { overflow: auto; }
|
||||||
|
[data-file-tree-virtualized-list] {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
[data-item-section="content"] {
|
||||||
|
min-width: max-content;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
[data-item-section="spacing"] { flex-shrink: 0; }
|
||||||
|
`,
|
||||||
|
// The host supplies the visible filter instead of Pierre's search input.
|
||||||
|
search: false,
|
||||||
|
searchFakeFocus: true,
|
||||||
|
searchBlurBehavior: 'retain',
|
||||||
|
fileTreeSearchMode: 'hide-non-matches',
|
||||||
|
composition: {
|
||||||
|
contextMenu: {
|
||||||
|
triggerMode: 'right-click',
|
||||||
|
onOpen(item, context) {
|
||||||
|
const entry = entries.get(item.path);
|
||||||
|
if (entry?.kind === 'file' || entry?.kind === 'symlink') {
|
||||||
|
closingMenuRef.current = false;
|
||||||
|
setActionError(undefined);
|
||||||
|
setMenu({ path: item.path, context });
|
||||||
|
} else {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose() {
|
||||||
|
closingMenuRef.current = true;
|
||||||
|
if (!disposed) setMenu(undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSearchChange(value) {
|
||||||
|
// Selection and Escape close search; normalized queries must not edit input.
|
||||||
|
if (value === null) setFilter('');
|
||||||
|
},
|
||||||
|
onSelectionChange(paths) {
|
||||||
|
const path = paths.at(-1);
|
||||||
|
const entry = path ? entries.get(path) : undefined;
|
||||||
|
if (path && (entry?.kind === 'file' || entry?.kind === 'symlink')) {
|
||||||
|
setSelectedPath(path);
|
||||||
|
setSource(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
treeRef.current = tree;
|
||||||
|
setDirectories([]);
|
||||||
|
setNoMatches(false);
|
||||||
|
tree.render({ fileTreeContainer: host });
|
||||||
|
const shadow = tree.getFileTreeContainer()?.shadowRoot;
|
||||||
|
const labelTree = () =>
|
||||||
|
shadow
|
||||||
|
?.querySelector('[role="tree"]')
|
||||||
|
?.setAttribute('aria-label', treeLabelRef.current);
|
||||||
|
const observer = new MutationObserver(labelTree);
|
||||||
|
if (shadow) observer.observe(shadow, { childList: true, subtree: true });
|
||||||
|
labelTree();
|
||||||
|
|
||||||
|
const publish = () => {
|
||||||
|
if (!disposed) setDirectories([...cache.values()]);
|
||||||
|
};
|
||||||
|
const load = async (path: string) => {
|
||||||
|
if (disposed || cache.has(path)) return;
|
||||||
|
cache.set(path, { path });
|
||||||
|
publish();
|
||||||
|
try {
|
||||||
|
const listing = await actions.listDirectory(path || '.');
|
||||||
|
if (disposed) return;
|
||||||
|
cache.set(path, {
|
||||||
|
path,
|
||||||
|
empty: listing.entries.length === 0,
|
||||||
|
truncated: listing.truncated,
|
||||||
|
});
|
||||||
|
const added = listing.entries.map((entry) => {
|
||||||
|
const child = `${path ? `${path}/` : ''}${entry.name}${entry.kind === 'directory' ? '/' : ''}`;
|
||||||
|
entries.set(child, entry);
|
||||||
|
return { type: 'add' as const, path: child };
|
||||||
|
});
|
||||||
|
if (added.length) tree.batch(added);
|
||||||
|
} catch (error) {
|
||||||
|
if (disposed) return;
|
||||||
|
cache.set(path, {
|
||||||
|
path,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
const unsubscribe = tree.subscribe(() => {
|
||||||
|
const searching = Boolean(tree.getSearchValue());
|
||||||
|
setNoMatches(searching && tree.getSearchMatchingPaths().length === 0);
|
||||||
|
// Search expands matching ancestors without requesting their contents.
|
||||||
|
if (searching) return;
|
||||||
|
for (const [path, entry] of entries) {
|
||||||
|
if (entry.kind !== 'directory') continue;
|
||||||
|
const item = tree.getItem(path);
|
||||||
|
if (item && 'isExpanded' in item && item.isExpanded()) {
|
||||||
|
void load(path.slice(0, -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
retryRef.current = (path) => {
|
||||||
|
cache.delete(path);
|
||||||
|
void load(path);
|
||||||
|
};
|
||||||
|
void load('');
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (lifetimeRef.current === lifetime) lifetimeRef.current = undefined;
|
||||||
|
observer.disconnect();
|
||||||
|
unsubscribe();
|
||||||
|
tree.cleanUp();
|
||||||
|
host.replaceChildren();
|
||||||
|
treeRef.current = undefined;
|
||||||
|
retryRef.current = undefined;
|
||||||
|
};
|
||||||
|
}, [actions, version]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
treeRef.current?.setSearch(filter || null);
|
||||||
|
}, [filter, version, actions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
treeRef.current
|
||||||
|
?.getFileTreeContainer()
|
||||||
|
?.shadowRoot?.querySelector('[role="tree"]')
|
||||||
|
?.setAttribute('aria-label', t('files.title'));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const runMenuAction = async (
|
||||||
|
action: () => void | Promise<void>,
|
||||||
|
transferFocus = false,
|
||||||
|
) => {
|
||||||
|
const lifetime = lifetimeRef.current;
|
||||||
|
closingMenuRef.current = true;
|
||||||
|
menu?.context.close({ restoreFocus: !transferFocus });
|
||||||
|
setMenu(undefined);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (error) {
|
||||||
|
if (lifetimeRef.current === lifetime) {
|
||||||
|
setActionError(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const separator = /^(?:[a-z]:[\\/]|\\\\)/i.test(workspaceCwd) ? '\\' : '/';
|
||||||
|
const absolutePath = (path: string) =>
|
||||||
|
path
|
||||||
|
? `${workspaceCwd.replace(separator === '\\' ? /[\\/]+$/ : /\/+$/, '')}${separator}${path.replaceAll('/', separator)}`
|
||||||
|
: workspaceCwd;
|
||||||
|
const absoluteMenuPath = absolutePath(menu?.path ?? '');
|
||||||
|
const canPreview =
|
||||||
|
selectedPath && /\.(?:md|markdown|html?)$/i.test(selectedPath);
|
||||||
|
const notices = directories.filter(
|
||||||
|
(directory) => directory.error || directory.empty || directory.truncated,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={styles.browser}
|
||||||
|
data-testid="workspace-file-browser"
|
||||||
|
style={{ colorScheme: theme }}
|
||||||
|
>
|
||||||
|
{menu && (
|
||||||
|
<DropdownMenu
|
||||||
|
open
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !closingMenuRef.current) {
|
||||||
|
closingMenuRef.current = true;
|
||||||
|
menu.context.close();
|
||||||
|
setMenu(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: menu.context.anchorRect.left,
|
||||||
|
top: menu.context.anchorRect.top,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-48"
|
||||||
|
aria-label={`${t('files.title')}: ${safeDisplayText(menu.path)}`}
|
||||||
|
aria-labelledby={undefined}
|
||||||
|
data-file-tree-context-menu-root="true"
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
menu.context.restoreFocus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(/\.(?:md|markdown|html?)$/i.test(menu.path) ||
|
||||||
|
getImageMimeTypeFromPath(menu.path) ||
|
||||||
|
isExcelFile(menu.path)) && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
void runMenuAction(() => onOpenPreview(menu.path), true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('attachment.showPreview')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={!onReferenceFile}
|
||||||
|
onSelect={() =>
|
||||||
|
void runMenuAction(() => onReferenceFile?.(menu.path), true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('files.reference')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
void runMenuAction(() => writeClipboardText(menu.path))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('files.copyRelativePath')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
void runMenuAction(() => writeClipboardText(absoluteMenuPath))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('files.copyAbsolutePath')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
const lifetime = lifetimeRef.current;
|
||||||
|
void runMenuAction(() =>
|
||||||
|
downloadWorkspaceFile(
|
||||||
|
actions,
|
||||||
|
menu.path,
|
||||||
|
undefined,
|
||||||
|
() => lifetimeRef.current !== lifetime,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.download')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<header className={styles.toolbar}>
|
||||||
|
<span
|
||||||
|
className={styles.path}
|
||||||
|
title={safeDisplayText(absolutePath(selectedPath ?? ''))}
|
||||||
|
>
|
||||||
|
{safeDisplayText(workspaceCwd.split(/[\\/]/).filter(Boolean).at(-1))}
|
||||||
|
{selectedPath && <> / {safeDisplayText(selectedPath)}</>}
|
||||||
|
</span>
|
||||||
|
{canPreview && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSource((value) => !value)}
|
||||||
|
>
|
||||||
|
{source ? <EyeIcon /> : <Code2Icon />}
|
||||||
|
{t(source ? 'attachment.showPreview' : 'attachment.showSource')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setVersion((value) => value + 1)}
|
||||||
|
aria-label={t('common.refresh')}
|
||||||
|
title={t('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon />
|
||||||
|
</Button>
|
||||||
|
{hasPreview && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
treeVisible
|
||||||
|
? treePanelRef.current?.collapse()
|
||||||
|
: treePanelRef.current?.expand()
|
||||||
|
}
|
||||||
|
aria-label={t('files.toggleTree')}
|
||||||
|
title={t('files.toggleTree')}
|
||||||
|
aria-pressed={treeVisible}
|
||||||
|
>
|
||||||
|
<FolderIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{actionError && (
|
||||||
|
<div className={styles.notices} role="alert">
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ResizablePanelGroup
|
||||||
|
className={styles.columns}
|
||||||
|
orientation="horizontal"
|
||||||
|
// Drawer pointer capture would retarget clicks out of Pierre's shadow DOM.
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize="0%"
|
||||||
|
minSize={hasPreview ? 120 : 0}
|
||||||
|
maxSize={hasPreview ? '100%' : '0%'}
|
||||||
|
panelRef={contentPanelRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.content}
|
||||||
|
data-testid="workspace-file-content"
|
||||||
|
hidden={!hasPreview}
|
||||||
|
>
|
||||||
|
{selectedPath && renderPreview(selectedPath, source, version)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle
|
||||||
|
className={styles.splitHandle}
|
||||||
|
hidden={!hasPreview || !treeVisible}
|
||||||
|
aria-label={t('files.resizeTree')}
|
||||||
|
/>
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize="100%"
|
||||||
|
minSize={150}
|
||||||
|
maxSize={hasPreview ? '70%' : '100%'}
|
||||||
|
collapsible
|
||||||
|
panelRef={treePanelRef}
|
||||||
|
onResize={(size) => setTreeVisible(size.asPercentage > 0)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.sidebar}
|
||||||
|
hidden={!treeVisible}
|
||||||
|
data-testid="workspace-file-sidebar"
|
||||||
|
>
|
||||||
|
<div className={styles.search}>
|
||||||
|
<SearchIcon aria-hidden />
|
||||||
|
<Input
|
||||||
|
data-workspace-file-filter
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key !== 'Escape' ||
|
||||||
|
!filter ||
|
||||||
|
event.nativeEvent.isComposing ||
|
||||||
|
event.nativeEvent.keyCode === 229
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setFilter('');
|
||||||
|
}}
|
||||||
|
placeholder={t('files.filter')}
|
||||||
|
aria-label={t('files.filter')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className={styles.tree}
|
||||||
|
hidden={noMatches}
|
||||||
|
onClickCapture={(event) => {
|
||||||
|
const row = event.nativeEvent
|
||||||
|
.composedPath()
|
||||||
|
.find(
|
||||||
|
(node) =>
|
||||||
|
node instanceof HTMLElement &&
|
||||||
|
node.getAttribute('role') === 'treeitem',
|
||||||
|
);
|
||||||
|
if (!(row instanceof HTMLElement) || row.matches(':focus'))
|
||||||
|
return;
|
||||||
|
const path = row.getAttribute('data-item-path');
|
||||||
|
const root = row.getRootNode();
|
||||||
|
const lifetime = lifetimeRef.current;
|
||||||
|
if (!path || !(root instanceof ShadowRoot)) return;
|
||||||
|
// Pierre can reuse the clicked DOM row before bubbling finishes.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (lifetimeRef.current !== lifetime) return;
|
||||||
|
root
|
||||||
|
.querySelector<HTMLElement>(
|
||||||
|
`[role="treeitem"][data-item-path="${CSS.escape(path)}"]`,
|
||||||
|
)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseDownCapture={(event) => {
|
||||||
|
// Native focus can scroll virtual rows before the click lands.
|
||||||
|
if (
|
||||||
|
event.button === 0 &&
|
||||||
|
event.nativeEvent
|
||||||
|
.composedPath()
|
||||||
|
.some(
|
||||||
|
(node) =>
|
||||||
|
node instanceof HTMLElement &&
|
||||||
|
node.getAttribute('role') === 'treeitem',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{noMatches && (
|
||||||
|
<div className={styles.noMatches} role="status">
|
||||||
|
{t('files.noMatches')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notices.length > 0 && (
|
||||||
|
<div className={styles.notices}>
|
||||||
|
{notices.map((directory) => (
|
||||||
|
<div key={directory.path}>
|
||||||
|
<span>
|
||||||
|
{directory.path
|
||||||
|
? safeDisplayText(directory.path)
|
||||||
|
: t('files.title')}
|
||||||
|
:{' '}
|
||||||
|
</span>
|
||||||
|
{directory.empty && (
|
||||||
|
<span>{t('files.emptyDirectory')}</span>
|
||||||
|
)}
|
||||||
|
{directory.truncated && (
|
||||||
|
<span role="status">{t('files.truncated')}</span>
|
||||||
|
)}
|
||||||
|
{directory.error && (
|
||||||
|
<>
|
||||||
|
<span role="alert">{directory.error}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => retryRef.current?.(directory.path)}
|
||||||
|
>
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const FILES_MESSAGES_EN = {};
|
||||||
|
export const FILES_MESSAGES_ZH = {};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const FILES_MESSAGES_EN = {
|
||||||
|
'files.copyRelativePath': 'Copy relative path',
|
||||||
|
'files.copyAbsolutePath': 'Copy absolute path',
|
||||||
|
'files.reference': 'Reference',
|
||||||
|
'files.title': 'Files',
|
||||||
|
'files.description': 'Browse workspace files',
|
||||||
|
'files.filter': 'Filter files…',
|
||||||
|
'files.noMatches': 'No matches in loaded paths',
|
||||||
|
'files.resizeTree': 'Resize file preview and tree',
|
||||||
|
'files.toggleTree': 'Toggle file tree',
|
||||||
|
'files.emptyDirectory': 'Empty folder',
|
||||||
|
'files.truncated': 'Only the first 2,000 entries are shown',
|
||||||
|
'files.loadFailed': 'File browser could not load',
|
||||||
|
'files.reloadUnavailable':
|
||||||
|
'Reloading could lose your connection or host-page state. Close Files and reopen it after the application is refreshed.',
|
||||||
|
'files.close': 'Close Files',
|
||||||
|
'files.sourceTooLarge':
|
||||||
|
'This file exceeds the 4 MiB source preview limit. Use Download in the file menu to read it locally.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FILES_MESSAGES_ZH = {
|
||||||
|
'files.copyRelativePath': '复制相对路径',
|
||||||
|
'files.copyAbsolutePath': '复制绝对路径',
|
||||||
|
'files.reference': '引用',
|
||||||
|
'files.title': '文件',
|
||||||
|
'files.description': '浏览工作区文件',
|
||||||
|
'files.filter': '筛选文件…',
|
||||||
|
'files.noMatches': '已加载路径内无匹配',
|
||||||
|
'files.resizeTree': '调整文件预览和目录树宽度',
|
||||||
|
'files.toggleTree': '显示或隐藏文件树',
|
||||||
|
'files.emptyDirectory': '空目录',
|
||||||
|
'files.truncated': '仅显示前 2,000 个条目',
|
||||||
|
'files.loadFailed': '无法加载文件浏览器',
|
||||||
|
'files.reloadUnavailable':
|
||||||
|
'重新加载可能丢失连接或宿主页面状态。请关闭文件,待应用刷新后重新打开。',
|
||||||
|
'files.close': '关闭文件',
|
||||||
|
'files.sourceTooLarge':
|
||||||
|
'文件超过 4 MiB 源码预览上限,请通过文件菜单下载后在本地查看。',
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
useWorkspace,
|
useWorkspace,
|
||||||
useWorkspaceActions,
|
useWorkspaceActions,
|
||||||
|
type DaemonDirectoryListing,
|
||||||
type DaemonFileStat,
|
type DaemonFileStat,
|
||||||
type DaemonWorkspaceActions,
|
type DaemonWorkspaceActions,
|
||||||
} from '@qwen-code/web-shell/daemon-react-sdk';
|
} from '@qwen-code/web-shell/daemon-react-sdk';
|
||||||
@@ -15,6 +16,7 @@ export type ArtifactWorkspaceActions = Pick<
|
|||||||
| 'readWorkspaceFile'
|
| 'readWorkspaceFile'
|
||||||
| 'readFileBytes'
|
| 'readFileBytes'
|
||||||
| 'stat'
|
| 'stat'
|
||||||
|
| 'listDirectory'
|
||||||
| 'listScheduledTasks'
|
| 'listScheduledTasks'
|
||||||
| 'updateScheduledTask'
|
| 'updateScheduledTask'
|
||||||
| 'deleteScheduledTask'
|
| 'deleteScheduledTask'
|
||||||
@@ -143,6 +145,16 @@ export function useArtifactWorkspaceTarget(
|
|||||||
return current;
|
return current;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
async listDirectory(path) {
|
||||||
|
const current = requireOwner();
|
||||||
|
const result = await (current.id
|
||||||
|
? (workspace.client
|
||||||
|
.workspaceByCwd(current.cwd)
|
||||||
|
.dirList(path) as Promise<DaemonDirectoryListing>)
|
||||||
|
: primaryActions.listDirectory(path));
|
||||||
|
requireOwner();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async readWorkspaceFile(filePath, opts) {
|
async readWorkspaceFile(filePath, opts) {
|
||||||
const current = requireOwner();
|
const current = requireOwner();
|
||||||
const result = await (current.primary
|
const result = await (current.primary
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from './popover';
|
} from './popover';
|
||||||
import { SelectTrigger } from './select';
|
import { SelectTrigger } from './select';
|
||||||
|
import {
|
||||||
|
ResizablePanelGroup,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandle,
|
||||||
|
} from './resizable';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -57,6 +62,9 @@ describe('React 18 ref compatibility', () => {
|
|||||||
['DropdownMenuTrigger', DropdownMenuTrigger],
|
['DropdownMenuTrigger', DropdownMenuTrigger],
|
||||||
['DropdownMenuSubTrigger', DropdownMenuSubTrigger],
|
['DropdownMenuSubTrigger', DropdownMenuSubTrigger],
|
||||||
['SelectTrigger', SelectTrigger],
|
['SelectTrigger', SelectTrigger],
|
||||||
|
['ResizablePanelGroup', ResizablePanelGroup],
|
||||||
|
['ResizablePanel', ResizablePanel],
|
||||||
|
['ResizableHandle', ResizableHandle],
|
||||||
['Table', Table],
|
['Table', Table],
|
||||||
['TableHeader', TableHeader],
|
['TableHeader', TableHeader],
|
||||||
['TableBody', TableBody],
|
['TableBody', TableBody],
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, createRef, StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from './resizable';
|
||||||
|
|
||||||
|
it('forwards group, panel and slotted separator refs to DOM elements', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'ResizeObserver',
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const groupRef = createRef<HTMLDivElement>();
|
||||||
|
const panelRef = createRef<HTMLDivElement>();
|
||||||
|
const handleRef = createRef<HTMLDivElement>();
|
||||||
|
try {
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<ResizablePanelGroup ref={groupRef}>
|
||||||
|
<ResizablePanel ref={panelRef}>Preview</ResizablePanel>
|
||||||
|
<Slot.Root ref={handleRef}>
|
||||||
|
<ResizableHandle aria-label="Resize panes" />
|
||||||
|
</Slot.Root>
|
||||||
|
<ResizablePanel>Tree</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</StrictMode>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(groupRef.current).toBe(container.querySelector('[data-group]'));
|
||||||
|
expect(panelRef.current).toBe(container.querySelector('[data-panel]'));
|
||||||
|
expect(handleRef.current).toBe(
|
||||||
|
container.querySelector('[role="separator"]'),
|
||||||
|
);
|
||||||
|
expect(handleRef.current).toBeInstanceOf(HTMLDivElement);
|
||||||
|
act(() => root.unmount());
|
||||||
|
expect(groupRef.current).toBeNull();
|
||||||
|
expect(panelRef.current).toBeNull();
|
||||||
|
expect(handleRef.current).toBeNull();
|
||||||
|
} finally {
|
||||||
|
if (container.hasChildNodes()) act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const ResizablePanelGroup = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<ResizablePrimitive.GroupProps, 'elementRef'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn('flex h-full w-full', className)}
|
||||||
|
{...props}
|
||||||
|
elementRef={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ResizablePanelGroup.displayName = 'ResizablePanelGroup';
|
||||||
|
|
||||||
|
const ResizablePanel = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<ResizablePrimitive.PanelProps, 'elementRef'>
|
||||||
|
>((props, ref) => (
|
||||||
|
<ResizablePrimitive.Panel
|
||||||
|
data-slot="resizable-panel"
|
||||||
|
{...props}
|
||||||
|
elementRef={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ResizablePanel.displayName = 'ResizablePanel';
|
||||||
|
|
||||||
|
const ResizableHandle = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<ResizablePrimitive.SeparatorProps, 'elementRef'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
'relative w-px shrink-0 bg-border focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
elementRef={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ResizableHandle.displayName = 'ResizableHandle';
|
||||||
|
|
||||||
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||||
@@ -201,6 +201,7 @@ export interface WebShellChatHeaderOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WebShellRightPanelItem =
|
export type WebShellRightPanelItem =
|
||||||
|
| 'files'
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'sideTask'
|
| 'sideTask'
|
||||||
| 'terminal'
|
| 'terminal'
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
FILES_MESSAGES_EN,
|
||||||
|
FILES_MESSAGES_ZH,
|
||||||
|
} from './components/artifacts/files-messages.js';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -1866,6 +1870,7 @@ const EN: Messages = {
|
|||||||
'Which workspace this conversation belongs to is not known yet.',
|
'Which workspace this conversation belongs to is not known yet.',
|
||||||
'localFiles.blocker.unsupportedDaemon':
|
'localFiles.blocker.unsupportedDaemon':
|
||||||
'This daemon does not advertise the client filesystem bridge (client_mcp_over_ws). Start the daemon with QWEN_SERVE_CLIENT_MCP_OVER_WS=1 to enable local files.',
|
'This daemon does not advertise the client filesystem bridge (client_mcp_over_ws). Start the daemon with QWEN_SERVE_CLIENT_MCP_OVER_WS=1 to enable local files.',
|
||||||
|
...FILES_MESSAGES_EN,
|
||||||
'rightPanel.add': 'Add panel',
|
'rightPanel.add': 'Add panel',
|
||||||
'attachment.showPreview': 'Preview',
|
'attachment.showPreview': 'Preview',
|
||||||
'attachment.showSource': 'Source',
|
'attachment.showSource': 'Source',
|
||||||
@@ -6144,6 +6149,7 @@ const ZH: Messages = {
|
|||||||
'localFiles.blocker.workspaceResolving': '尚不能确定该会话所属的工作区。',
|
'localFiles.blocker.workspaceResolving': '尚不能确定该会话所属的工作区。',
|
||||||
'localFiles.blocker.unsupportedDaemon':
|
'localFiles.blocker.unsupportedDaemon':
|
||||||
'该 daemon 未启用客户端文件桥(client_mcp_over_ws)。以 QWEN_SERVE_CLIENT_MCP_OVER_WS=1 启动 daemon 即可启用本地文件。',
|
'该 daemon 未启用客户端文件桥(client_mcp_over_ws)。以 QWEN_SERVE_CLIENT_MCP_OVER_WS=1 启动 daemon 即可启用本地文件。',
|
||||||
|
...FILES_MESSAGES_ZH,
|
||||||
'rightPanel.add': '添加页签',
|
'rightPanel.add': '添加页签',
|
||||||
'attachment.showPreview': '预览',
|
'attachment.showPreview': '预览',
|
||||||
'attachment.showSource': '源码',
|
'attachment.showSource': '源码',
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
|
|||||||
},
|
},
|
||||||
rightPanel: {
|
rightPanel: {
|
||||||
items: [
|
items: [
|
||||||
|
'files',
|
||||||
'review',
|
'review',
|
||||||
'sideTask',
|
'sideTask',
|
||||||
'terminal',
|
'terminal',
|
||||||
|
|||||||
@@ -29,17 +29,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"NOTICES.txt",
|
||||||
"dist/*.js",
|
"dist/*.js",
|
||||||
"dist/types"
|
"dist/types"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && vite build --config vite.lib.config.ts && vite build --config vite.lib.config.ts --mode transcript && tsc -p tsconfig.lib.json",
|
"build": "vite build && vite build --config vite.lib.config.ts && vite build --config vite.lib.config.ts --mode transcript && tsc -p tsconfig.lib.json && npm run generate:notices",
|
||||||
"lint": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx",
|
"lint": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx",
|
||||||
"lint:fix": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx --fix",
|
"lint:fix": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx --fix",
|
||||||
"format:check": "cd ../.. && prettier --experimental-cli --check packages/web-shell",
|
"format:check": "cd ../.. && prettier --experimental-cli --check packages/web-shell",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"generate:managed-agent-api": "node scripts/generate-managed-agent-api.mjs",
|
"generate:managed-agent-api": "node scripts/generate-managed-agent-api.mjs",
|
||||||
|
"generate:notices": "node ../vscode-ide-companion/scripts/generate-notices.js --bundled .",
|
||||||
"test": "vitest run --config vitest.config.ts",
|
"test": "vitest run --config vitest.config.ts",
|
||||||
"test:ci": "vitest run --config vitest.config.ts",
|
"test:ci": "vitest run --config vitest.config.ts",
|
||||||
"test:coverage": "vitest run --config vitest.config.ts --coverage",
|
"test:coverage": "vitest run --config vitest.config.ts --coverage",
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
"test:e2e:visuals": "playwright test --config playwright.visuals.config.ts",
|
"test:e2e:visuals": "playwright test --config playwright.visuals.config.ts",
|
||||||
"test:e2e:report": "playwright show-report client/e2e/playwright-report",
|
"test:e2e:report": "playwright show-report client/e2e/playwright-report",
|
||||||
"verify": "npm run lint && npm run format:check && npm run typecheck && npm run test:ci",
|
"verify": "npm run lint && npm run format:check && npm run typecheck && npm run test:ci",
|
||||||
"prepublishOnly": "node scripts/verify-publish-artifacts.mjs"
|
"prepublishOnly": "npm run generate:notices && node scripts/verify-publish-artifacts.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
"mermaid": "^11.15.0",
|
"mermaid": "^11.15.0",
|
||||||
"radix-ui": "^1.6.2",
|
"radix-ui": "^1.6.2",
|
||||||
"react-markdown": "^9.0.0",
|
"react-markdown": "^9.0.0",
|
||||||
|
"react-resizable-panels": "4.14.3",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-cjk-friendly": "^2.3.1",
|
"remark-cjk-friendly": "^2.3.1",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
@@ -92,7 +95,13 @@
|
|||||||
"react": "^18.0.0 || ^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
},
|
},
|
||||||
|
"bundledDevDependencies": [
|
||||||
|
"@pierre/diffs",
|
||||||
|
"@pierre/trees"
|
||||||
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@pierre/diffs": "1.5.2",
|
||||||
|
"@pierre/trees": "1.0.0-beta.6",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.61.1",
|
||||||
"@qwen-code/acp-bridge": "file:../acp-bridge",
|
"@qwen-code/acp-bridge": "file:../acp-bridge",
|
||||||
"@qwen-code/sdk": "file:../sdk-typescript",
|
"@qwen-code/sdk": "file:../sdk-typescript",
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ try {
|
|||||||
// membership check have to be reduced to that form first.
|
// membership check have to be reduced to that form first.
|
||||||
const packPath = (target) => relative(root, target).split(sep).join('/');
|
const packPath = (target) => relative(root, target).split(sep).join('/');
|
||||||
|
|
||||||
|
if (packed && !packed.has('NOTICES.txt')) {
|
||||||
|
problems.push(
|
||||||
|
'bundled dependency NOTICES.txt is missing from the npm package',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A `.`/`..` path segment (also percent-encoded, which Node rejects the same
|
// A `.`/`..` path segment (also percent-encoded, which Node rejects the same
|
||||||
// way) anywhere in a declared `exports` target. Deliberately not `_`: Node
|
// way) anywhere in a declared `exports` target. Deliberately not `_`: Node
|
||||||
// resolves `./dist/_/*.js` normally.
|
// resolves `./dist/_/*.js` normally.
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ function configureDevelopmentMiddleware(
|
|||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
root: 'client',
|
root: 'client',
|
||||||
|
// Worker-only imports otherwise trigger a dev-page reload on first preview.
|
||||||
|
optimizeDeps: { include: ['exceljs', 'ssf'] },
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ function injectCssModules(): Plugin {
|
|||||||
const TRANSCRIPT_STUBS = new Map(
|
const TRANSCRIPT_STUBS = new Map(
|
||||||
[
|
[
|
||||||
['./client/live/messages.ts', './client/live/messages.transcript-stub.ts'],
|
['./client/live/messages.ts', './client/live/messages.transcript-stub.ts'],
|
||||||
|
[
|
||||||
|
'./client/components/artifacts/files-messages.ts',
|
||||||
|
'./client/components/artifacts/files-messages.transcript-stub.ts',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'./client/voice/messages.ts',
|
'./client/voice/messages.ts',
|
||||||
'./client/voice/messages.transcript-stub.ts',
|
'./client/voice/messages.transcript-stub.ts',
|
||||||
@@ -256,38 +260,45 @@ export default defineConfig(({ mode }) => ({
|
|||||||
fileName: (_format, entryName) => `${entryName}.js`,
|
fileName: (_format, entryName) => `${entryName}.js`,
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: (id, importer) => {
|
||||||
'react',
|
// Pierre requires Shiki 3/4; keep its copy inside the lazy preview bundle.
|
||||||
'react/jsx-runtime',
|
if (id === 'shiki') return !importer?.includes('/@pierre/');
|
||||||
'react/jsx-dev-runtime',
|
return [
|
||||||
'react-dom',
|
'react',
|
||||||
'react-dom/client',
|
'react/jsx-runtime',
|
||||||
'radix-ui',
|
'react/jsx-dev-runtime',
|
||||||
'lucide-react',
|
'react-dom',
|
||||||
'class-variance-authority',
|
'react-dom/client',
|
||||||
'clsx',
|
'radix-ui',
|
||||||
'tailwind-merge',
|
'lucide-react',
|
||||||
'vaul',
|
'class-variance-authority',
|
||||||
'@qwen-code/sdk',
|
'clsx',
|
||||||
/^@qwen-code\/sdk\//,
|
'tailwind-merge',
|
||||||
'@datafe-open/markdown-chart',
|
'vaul',
|
||||||
'@datafe-open/markdown-chart-echarts',
|
'@qwen-code/sdk',
|
||||||
'@datafe-open/markdown-chart-react',
|
/^@qwen-code\/sdk\//,
|
||||||
'echarts',
|
'@datafe-open/markdown-chart',
|
||||||
/^echarts\//,
|
'@datafe-open/markdown-chart-echarts',
|
||||||
'react-markdown',
|
'@datafe-open/markdown-chart-react',
|
||||||
'remark-cjk-friendly',
|
'echarts',
|
||||||
/^remark-cjk-friendly\//,
|
/^echarts\//,
|
||||||
'remark-gfm',
|
'react-markdown',
|
||||||
'remark-math',
|
'remark-cjk-friendly',
|
||||||
'rehype-katex',
|
/^remark-cjk-friendly\//,
|
||||||
'shiki',
|
'remark-gfm',
|
||||||
'mermaid',
|
'remark-math',
|
||||||
'katex',
|
'rehype-katex',
|
||||||
/^katex\/(?!dist\/katex\.min\.css$)/,
|
'mermaid',
|
||||||
'codemirror',
|
'katex',
|
||||||
/^@codemirror\//,
|
/^katex\/(?!dist\/katex\.min\.css$)/,
|
||||||
],
|
'codemirror',
|
||||||
|
/^@codemirror\//,
|
||||||
|
].some((dependency) =>
|
||||||
|
typeof dependency === 'string'
|
||||||
|
? dependency === id
|
||||||
|
: dependency.test(id),
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
|
|||||||
Generated
+263
@@ -1578,6 +1578,9 @@ importers:
|
|||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.1.0(@types/[email protected])([email protected])([email protected])
|
version: 9.1.0(@types/[email protected])([email protected])([email protected])
|
||||||
|
react-resizable-panels:
|
||||||
|
specifier: 4.14.3
|
||||||
|
version: 4.14.3([email protected]([email protected]))([email protected])
|
||||||
rehype-katex:
|
rehype-katex:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
@@ -1606,6 +1609,12 @@ importers:
|
|||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@pierre/diffs':
|
||||||
|
specifier: 1.5.2
|
||||||
|
version: 1.5.2(@shikijs/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@pierre/trees':
|
||||||
|
specifier: 1.0.0-beta.6
|
||||||
|
version: 1.0.0-beta.6(@pierre/[email protected])(@shikijs/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.61.1
|
specifier: 1.61.1
|
||||||
version: 1.61.1
|
version: 1.61.1
|
||||||
@@ -3511,6 +3520,67 @@ packages:
|
|||||||
'@paralleldrive/[email protected]':
|
'@paralleldrive/[email protected]':
|
||||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||||
|
|
||||||
|
'@pierre/[email protected]':
|
||||||
|
resolution: {integrity: sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.2.4
|
||||||
|
react-dom: ^19.2.4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pierre/[email protected]':
|
||||||
|
resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==}
|
||||||
|
engines: {vscode: ^1.0.0}
|
||||||
|
|
||||||
|
'@pierre/[email protected]':
|
||||||
|
resolution: {integrity: sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@pierre/theme': ^1.1.0
|
||||||
|
'@shikijs/themes': ^3.0.0 || ^4.0.0
|
||||||
|
react: ^19.2.4
|
||||||
|
react-dom: ^19.2.4
|
||||||
|
shiki: ^3.0.0 || ^4.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@pierre/theme':
|
||||||
|
optional: true
|
||||||
|
'@shikijs/themes':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
shiki:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pierre/[email protected]':
|
||||||
|
resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@pierre/theme': ^1.1.0 || ^2.0.0
|
||||||
|
'@shikijs/themes': ^3.0.0 || ^4.0.0
|
||||||
|
react: ^19.2.4
|
||||||
|
react-dom: ^19.2.4
|
||||||
|
shiki: ^3.0.0 || ^4.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@pierre/theme':
|
||||||
|
optional: true
|
||||||
|
'@shikijs/themes':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
shiki:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pierre/[email protected]':
|
||||||
|
resolution: {integrity: sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.2.4
|
||||||
|
react-dom: ^19.2.4
|
||||||
|
|
||||||
'@pinojs/[email protected]':
|
'@pinojs/[email protected]':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -4450,21 +4520,53 @@ packages:
|
|||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==}
|
resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==}
|
resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==}
|
resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==}
|
resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==}
|
resolution: {integrity: sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-LM7twi043qCC3gRyGQfjoC2/sowzbEltWuruyRtT3RlBlk1hKzWk9ETnU7RcDBoVWQBmC8Mebnr7p28W7kPoaA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==}
|
resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==}
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||||
|
|
||||||
@@ -8106,6 +8208,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
|
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -8713,9 +8818,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -9104,6 +9215,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==}
|
||||||
|
peerDependencies:
|
||||||
|
preact: '>=10 || >= 11.0.0-0'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -9300,6 +9419,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-t/mJDWKBvc7sHcm/HJxJJKniqyQH6vK7oWyv8QODawYCcDwn0waBlGw0yFEwoMe6xPNKpbOFh2wN0yyina30ow==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.2.4
|
||||||
|
react-dom: ^19.2.4
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -9377,12 +9502,18 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
|
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
|
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -9642,6 +9773,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
|
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -12657,6 +12792,51 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.8.0
|
'@noble/hashes': 1.8.0
|
||||||
|
|
||||||
|
'@pierre/[email protected](@shikijs/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@pierre/theme': 2.0.0
|
||||||
|
'@pierre/theming': 1.0.1(@pierre/[email protected])(@shikijs/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
|
'@shikijs/transformers': 4.5.0
|
||||||
|
diff: 9.0.0
|
||||||
|
hast-util-to-html: 9.0.5
|
||||||
|
lru_map: 0.4.1
|
||||||
|
shiki: 4.5.0
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@shikijs/themes'
|
||||||
|
|
||||||
|
'@pierre/[email protected]': {}
|
||||||
|
|
||||||
|
'@pierre/[email protected](@pierre/[email protected])(@shikijs/[email protected])([email protected]([email protected]))([email protected])([email protected])':
|
||||||
|
optionalDependencies:
|
||||||
|
'@pierre/theme': 2.0.0
|
||||||
|
'@shikijs/themes': 4.5.0
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4([email protected])
|
||||||
|
shiki: 1.29.2
|
||||||
|
|
||||||
|
'@pierre/[email protected](@pierre/[email protected])(@shikijs/[email protected])([email protected]([email protected]))([email protected])([email protected])':
|
||||||
|
optionalDependencies:
|
||||||
|
'@pierre/theme': 2.0.0
|
||||||
|
'@shikijs/themes': 4.5.0
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4([email protected])
|
||||||
|
shiki: 4.5.0
|
||||||
|
|
||||||
|
'@pierre/[email protected](@pierre/[email protected])(@shikijs/[email protected])([email protected]([email protected]))([email protected])([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@pierre/theming': 1.0.0(@pierre/[email protected])(@shikijs/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
|
preact: 11.0.0-beta.0
|
||||||
|
preact-render-to-string: 6.6.5([email protected])
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@pierre/theme'
|
||||||
|
- '@shikijs/themes'
|
||||||
|
- shiki
|
||||||
|
|
||||||
'@pinojs/[email protected]': {}
|
'@pinojs/[email protected]': {}
|
||||||
|
|
||||||
'@pkgjs/[email protected]':
|
'@pkgjs/[email protected]':
|
||||||
@@ -13625,30 +13805,73 @@ snapshots:
|
|||||||
'@types/hast': 3.0.5
|
'@types/hast': 3.0.5
|
||||||
hast-util-to-html: 9.0.5
|
hast-util-to-html: 9.0.5
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/primitive': 4.5.0
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
'@types/hast': 3.0.5
|
||||||
|
hast-util-to-html: 9.0.5
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.2
|
'@shikijs/types': 1.29.2
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
oniguruma-to-es: 2.3.0
|
oniguruma-to-es: 2.3.0
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
oniguruma-to-es: 4.3.6
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.2
|
'@shikijs/types': 1.29.2
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.2
|
'@shikijs/types': 1.29.2
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.2
|
'@shikijs/types': 1.29.2
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/core': 4.5.0
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
|
||||||
'@shikijs/[email protected]':
|
'@shikijs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
'@types/hast': 3.0.5
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
|
'@shikijs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
'@shikijs/[email protected]': {}
|
'@shikijs/[email protected]': {}
|
||||||
|
|
||||||
'@simple-git/[email protected]': {}
|
'@simple-git/[email protected]': {}
|
||||||
@@ -18011,6 +18234,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 4.0.0
|
yallist: 4.0.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -18919,12 +19144,20 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex-xs: 1.0.0
|
emoji-regex-xs: 1.0.0
|
||||||
regex: 5.1.1
|
regex: 5.1.1
|
||||||
regex-recursion: 5.1.1
|
regex-recursion: 5.1.1
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
oniguruma-parser: 0.12.2
|
||||||
|
regex: 6.1.0
|
||||||
|
regex-recursion: 6.0.2
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
default-browser: 5.5.0
|
default-browser: 5.5.0
|
||||||
@@ -19299,6 +19532,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
preact: 11.0.0-beta.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
@@ -19587,6 +19826,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.10
|
'@types/react': 19.2.10
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4([email protected])
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
@@ -19692,12 +19936,20 @@ snapshots:
|
|||||||
regex: 5.1.1
|
regex: 5.1.1
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -20136,6 +20388,17 @@ snapshots:
|
|||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
'@types/hast': 3.0.5
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/core': 4.5.0
|
||||||
|
'@shikijs/engine-javascript': 4.5.0
|
||||||
|
'@shikijs/engine-oniguruma': 4.5.0
|
||||||
|
'@shikijs/langs': 4.5.0
|
||||||
|
'@shikijs/themes': 4.5.0
|
||||||
|
'@shikijs/types': 4.5.0
|
||||||
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|||||||
@@ -482,8 +482,8 @@ describe('post-merge push lane', () => {
|
|||||||
'Run i18n check',
|
'Run i18n check',
|
||||||
'Generate settings schema',
|
'Generate settings schema',
|
||||||
'Check settings schema is up-to-date',
|
'Check settings schema is up-to-date',
|
||||||
'Generate VS Code companion notices',
|
'Generate Web Shell and VS Code companion notices',
|
||||||
'Check VS Code companion notices are up-to-date',
|
'Check Web Shell and VS Code companion notices are up-to-date',
|
||||||
'Check serve fast-path bundle closure',
|
'Check serve fast-path bundle closure',
|
||||||
'Check core subpath exports resolve',
|
'Check core subpath exports resolve',
|
||||||
'Run .github/scripts helper tests',
|
'Run .github/scripts helper tests',
|
||||||
@@ -503,6 +503,30 @@ describe('post-merge push lane', () => {
|
|||||||
)
|
)
|
||||||
.map((x) => x.name),
|
.map((x) => x.name),
|
||||||
).toEqual(FULL_PAYLOAD);
|
).toEqual(FULL_PAYLOAD);
|
||||||
|
const generateNotices = stepByName.get(
|
||||||
|
'Generate Web Shell and VS Code companion notices',
|
||||||
|
).run;
|
||||||
|
expect(generateNotices).toContain(
|
||||||
|
'npm run generate:notices --workspace=@qwen-code/web-shell',
|
||||||
|
);
|
||||||
|
expect(generateNotices).toContain(
|
||||||
|
'npm run generate:notices --workspace=qwen-code-vscode-ide-companion',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stepByName.get(
|
||||||
|
'Check Web Shell and VS Code companion notices are up-to-date',
|
||||||
|
).run,
|
||||||
|
).toContain(
|
||||||
|
'git status --porcelain packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt',
|
||||||
|
);
|
||||||
|
const checkNotices = stepByName.get(
|
||||||
|
'Check Web Shell and VS Code companion notices are up-to-date',
|
||||||
|
);
|
||||||
|
expect(checkNotices['continue-on-error']).toBeUndefined();
|
||||||
|
expect(checkNotices.run).toContain(
|
||||||
|
'if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt) ]]; then',
|
||||||
|
);
|
||||||
|
expect(checkNotices.run).toContain('exit 1');
|
||||||
for (const n of FULL_PAYLOAD) {
|
for (const n of FULL_PAYLOAD) {
|
||||||
expect(testNames.has(n), `${n} leaked back into test`).toBe(false);
|
expect(testNames.has(n), `${n} leaked back into test`).toBe(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,16 +65,34 @@ function write(fixture, name, contents) {
|
|||||||
const files = ['dist/*.js', 'dist/types'];
|
const files = ['dist/*.js', 'dist/types'];
|
||||||
|
|
||||||
function declarePackage(fixture, exports, packageFiles = files) {
|
function declarePackage(fixture, exports, packageFiles = files) {
|
||||||
|
write(fixture, 'NOTICES.txt', 'Bundled dependency licenses');
|
||||||
write(fixture, 'package.json', {
|
write(fixture, 'package.json', {
|
||||||
name: 'fixture-web-shell',
|
name: 'fixture-web-shell',
|
||||||
version: '0.0.0',
|
version: '0.0.0',
|
||||||
type: 'module',
|
type: 'module',
|
||||||
files: packageFiles,
|
files: ['NOTICES.txt', ...packageFiles],
|
||||||
exports,
|
exports,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('web-shell publish artifact verifier', () => {
|
describe('web-shell publish artifact verifier', () => {
|
||||||
|
it('refuses bundled dependency notices excluded from the npm package', () => {
|
||||||
|
const result = runVerifier((fixture) => {
|
||||||
|
write(fixture, 'NOTICES.txt', 'Bundled dependency licenses');
|
||||||
|
write(fixture, 'dist/index.js', 'export {};');
|
||||||
|
write(fixture, 'package.json', {
|
||||||
|
name: 'fixture-web-shell',
|
||||||
|
version: '0.0.0',
|
||||||
|
type: 'module',
|
||||||
|
files,
|
||||||
|
exports: { '.': './dist/index.js' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'NOTICES.txt is missing from the npm package',
|
||||||
|
);
|
||||||
|
});
|
||||||
it('refuses an export target that exists on disk but is left out of the tarball', () => {
|
it('refuses an export target that exists on disk but is left out of the tarball', () => {
|
||||||
const result = runVerifier((fixture) => {
|
const result = runVerifier((fixture) => {
|
||||||
declarePackage(fixture, {
|
declarePackage(fixture, {
|
||||||
|
|||||||
Reference in new issue
Block a user