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:
ytahdnand钉萁 authored and GitHub committed 2026-10-13 09:38:47 +00:00
1 parent afe4dd7113
commit df5470e430
38 files changed
+8559 -152

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
*.bash eol=lf
Makefile eol=lf
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.
scripts/installation/install-qwen-standalone.bat text eol=crlf
+10 -7
View File
@@ -1257,18 +1257,21 @@ jobs:
fi
echo "Settings schema is up-to-date"
- name: 'Generate 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'
- 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: |-
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 "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."
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
fi
echo "NOTICES.txt is up-to-date"
+137
View File
@@ -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.
+101
View File
@@ -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> {
const query = new URLSearchParams({ path: dirPath });
return this.get(
return this.client.workspaceJsonRequest(
this.workspaceSelector,
`/list?${query.toString()}`,
'GET /workspaces/:workspace/list',
{ mode: 'rest' },
);
}
@@ -10740,6 +10740,78 @@ describe('DaemonClient', () => {
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 () => {
const body = {
v: 1,
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)), '..', '..', '..'),
);
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
@@ -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
// are not in that bundle; its actual embedded packages are inventoried below.
if (
@@ -452,77 +457,84 @@ export async function getExceljsBrowserNotices(packageDir) {
return inventory.packages;
}
async function main() {
try {
const packageJsonPath = path.join(packagePath, 'package.json');
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);
async function main({ packageDir = packagePath, bundledOnly = false } = {}) {
const packageJsonPath = path.join(packageDir, 'package.json');
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);
const allDependencies = new Map();
const visitedDirs = new Set();
const directDependencies = Object.keys(packageJson.dependencies);
const allDependencies = new Map();
const visitedDirs = new Set();
const directDependencies = bundledOnly
? (packageJson.bundledDevDependencies ?? [])
: Object.keys(packageJson.dependencies);
// 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,
packagePath,
allDependencies,
visitedDirs,
);
if (bundledOnly) {
if (directDependencies.length === 0)
throw new Error('No bundled dependency seeds declared');
for (const name of directDependencies) {
if (!(await resolveInstalled(name, packageDir)))
throw new Error(`Bundled dependency ${name} is not installed`);
}
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(
(env.QWEN_SKIP_NOTICE_GENERATION ?? '').toLowerCase(),
);
@@ -534,7 +546,7 @@ export async function runNoticeGeneration(env = process.env) {
return;
}
await main();
await main(options);
}
// Only run when executed directly (e.g. `npm run generate:notices`), not when
@@ -543,5 +555,12 @@ if (
process.argv[1] &&
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 fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
@@ -392,6 +394,95 @@ describe('collectDependencies', () => {
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', () => {
+2435
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -41,6 +41,28 @@ Shell 执行。卡片区分执行、捕获与交付状态,面板按固定版
安装。关闭 `general.enableAutoUpdate`、旧服务、桌面 shell、不支持的安装及嵌入式
服务不显示按钮。临时配对连接也不提供更新入口。使用自定义 `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 的右侧面板提供「网页预览」。先通过终端启动开发服务器,
+410 -1
View File
@@ -8,7 +8,9 @@ import {
type ReactNode,
} from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createPortal } from 'react-dom';
const notificationScrollToBottom = vi.hoisted(() => vi.fn());
const editorAddTags = vi.hoisted(() => vi.fn());
import type {
ContextUsageControls,
RegisterContextUsageControls,
@@ -797,6 +799,7 @@ const {
latestSettingsState: null as {
settings: DaemonSettingDescriptor[];
} | null,
artifactPanelExtra: null as ReactNode,
captureArtifactPanelOnly: false,
latestArtifactPanelProps: null as React.ComponentProps<
typeof ArtifactPanel
@@ -897,7 +900,10 @@ vi.mock('./components/artifacts/ArtifactPanel', async (importOriginal) => {
ArtifactPanel: (props: React.ComponentProps<typeof ArtifactPanel>) => {
testState.latestArtifactPanelProps = props;
return testState.captureArtifactPanelOnly ? null : (
<actual.ArtifactPanel {...props} />
<>
<actual.ArtifactPanel {...props} />
{testState.artifactPanelExtra}
</>
);
},
};
@@ -1159,6 +1165,7 @@ vi.mock('./components/ChatEditor', async () => {
props: ChatEditorTestProps,
ref: React.ForwardedRef<{
clear: () => void;
addTags: (tags: unknown, options?: unknown) => void;
hasAttachments: () => boolean;
hasInput: () => boolean;
insertText: (text: string) => void;
@@ -1209,6 +1216,7 @@ vi.mock('./components/ChatEditor', async () => {
testState.inputAnnotations?.length,
),
hasInput: () => testState.prompt.trim().length > 0,
addTags: editorAddTags,
insertText: editorInsertText,
getText: () => testState.prompt,
setText: (text) => {
@@ -9861,6 +9869,36 @@ describe('artifact panel fullscreen', () => {
await Promise.resolve();
});
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 () => {
@@ -9896,6 +9934,16 @@ describe('artifact panel fullscreen', () => {
exitFullscreen!.focus();
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
// the surface: the keydown that moved it never reached the surface's
// Tab-wrap handler. Pull the focus back so the keyboard stays the
@@ -9939,6 +9987,110 @@ describe('artifact panel fullscreen', () => {
).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 () => {
const { container } = renderApp({ shadowDom: { portals: true } });
await flush();
@@ -11685,6 +11837,7 @@ beforeEach(() => {
testState.latestWorkflowRunsProps = null;
testState.latestSplitViewProps = null;
testState.latestArtifactPanelProps = null;
testState.artifactPanelExtra = null;
testState.captureArtifactPanelOnly = false;
rawEnqueuePrompt.mockClear();
editorClear.mockClear();
@@ -16754,6 +16907,262 @@ describe('App session callbacks', () => {
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', () => {
mockConnection.capabilities.features = ['web_terminal'];
const { container } = renderApp({
+179 -27
View File
@@ -1,3 +1,9 @@
import {
createComposerTagForItem,
fileReferenceInsertText,
FILE_PROVIDER_ID,
sanitizeAtMentionItem,
} from './hooks/useAtMentionSources';
import {
isModelSetupCommand,
resolveModelManagement,
@@ -564,9 +570,9 @@ function isWebTerminalTarget(event: Event): boolean {
// 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
// escape route; wrap it at the tabbable edges like FocusScope's loop does.
function getFullscreenSurfaceTabEdges(
container: HTMLElement,
): [HTMLElement | null, HTMLElement | null] {
function getArtifactPanelTabStops(
container: HTMLElement | ShadowRoot,
): HTMLElement[] {
const candidates: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
@@ -584,20 +590,23 @@ function getFullscreenSurfaceTabEdges(
) {
return NodeFilter.FILTER_SKIP;
}
return element.tabIndex >= 0
return element.tabIndex >= 0 || element.shadowRoot
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
});
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'
? element.checkVisibility({ checkVisibilityCSS: true })
: true,
);
return [visible[0] ?? null, visible[visible.length - 1] ?? null];
}
const DEFAULT_COMPOSER_TOOLBAR_ACTIONS = [
'approvalMode',
@@ -1937,6 +1946,7 @@ interface ArtifactPanelPersistedState {
}
type PersistedArtifactPanelTab =
| Extract<ArtifactPanelTab, { kind: 'files' }>
| Extract<ArtifactPanelTab, { kind: 'web_preview' }>
| Omit<Extract<ArtifactPanelTab, { kind: 'turn_calls' }>, 'promptLabel'>
| Pick<
@@ -2067,6 +2077,15 @@ function parsePersistedArtifactPanelTab(
}
const common = { id: tab['id'], title: tab['title'] };
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':
if (
tab['id'] !== 'turn_calls' ||
@@ -2244,6 +2263,8 @@ function serializeArtifactPanelTabs(
return tabs.flatMap((tab): PersistedArtifactPanelTab[] => {
const { id, title } = tab;
switch (tab.kind) {
case 'files':
return [tab];
case 'web_preview':
return [
{ id, title, kind: tab.kind, url: tab.url, viewport: tab.viewport },
@@ -5188,6 +5209,15 @@ export function App({
Boolean(connection.sessionId && connection.workspaceCwd) &&
connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) ===
true;
const filesAvailable =
workspaceContextActive &&
rightPanelItems.includes('files') &&
Boolean(
resolveArtifactWorkspaceOwner(
workspace.capabilities,
connection.workspaceCwd,
),
);
const webPreviewAvailable =
workspaceContextActive && rightPanelItems.includes('webPreview');
const trajectoryAvailable = rightPanelItems.includes('trajectory');
@@ -6563,6 +6593,7 @@ export function App({
).some(
(tab) =>
(tab.kind === 'terminal' && webTerminalAvailable) ||
(tab.kind === 'files' && filesAvailable) ||
(tab.kind === 'web_preview' && webPreviewAvailable) ||
(tab.kind === 'workflow' &&
tab.sessionId === connection.sessionId &&
@@ -6658,6 +6689,7 @@ export function App({
const deferredPersistedTabs = (persisted?.tabs ?? []).filter(
(tab) =>
(tab.kind === 'terminal' && !webTerminalAvailable) ||
(tab.kind === 'files' && !filesAvailable) ||
(tab.kind === 'web_preview' && !webPreviewAvailable) ||
(tab.kind === 'workflow' &&
tab.sessionId === connection.sessionId &&
@@ -6742,6 +6774,8 @@ export function App({
}
: undefined;
}
case 'files':
return filesAvailable ? tab : undefined;
case 'web_preview':
return webPreviewAvailable ? tab : undefined;
case 'file': {
@@ -7005,6 +7039,7 @@ export function App({
sessionActions,
trajectoryAvailable,
webTerminalAvailable,
filesAvailable,
webPreviewAvailable,
workspace.baseUrl,
workspace.client,
@@ -7048,6 +7083,36 @@ export function App({
},
[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(
(tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => {
if (getSubagentDetailsUnavailableReason(tool)) return;
@@ -7404,16 +7469,20 @@ export function App({
);
const openFilePreview = useCallback(
(
change: TurnOutputFileChange,
change: TurnOutputFileChange | string,
previewWorkspaceCwd?: 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 = {
id: `file:${previewWorkspaceCwd ?? ''}:${change.path}`,
id: `file:${previewWorkspaceCwd ?? ''}:${path}`,
kind: 'file',
title: displayPath(change.path, previewWorkspaceCwd),
workspacePath: change.path,
title: displayPath(path, previewWorkspaceCwd),
workspacePath: path,
...(previewWorkspaceCwd ? { workspaceCwd: previewWorkspaceCwd } : {}),
...(previewWorkspaceId ? { workspaceId: previewWorkspaceId } : {}),
...(previewContent !== undefined ? { previewContent } : {}),
@@ -9140,7 +9209,11 @@ export function App({
const target =
(event.composedPath()[0] as Element | undefined) ??
(event.target as Element | null);
if (!target || surface.contains(target) || target.contains(surface)) {
if (
!target ||
event.composedPath().includes(surface) ||
target.contains(surface)
) {
return;
}
// The elevated toast host and any Radix layer opened from the panel
@@ -9178,8 +9251,9 @@ export function App({
}, [dockedFullscreenActive, portalRoot]);
const handleArtifactPanelSurfaceKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!artifactPanelFullscreen) return;
if (!artifactPanelFullscreen && !useFloatingArtifactPanel) return;
if (
event.defaultPrevented ||
event.key !== 'Tab' ||
event.altKey ||
event.ctrlKey ||
@@ -9187,12 +9261,37 @@ export function App({
) {
return;
}
const [first, last] = getFullscreenSurfaceTabEdges(event.currentTarget);
const focused = getShadowAwareActiveElement(event.currentTarget);
if (!event.nativeEvent.composedPath().includes(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 (focused === event.currentTarget) event.preventDefault();
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) {
event.preventDefault();
first.focus();
@@ -9204,7 +9303,7 @@ export function App({
last.focus();
}
},
[artifactPanelFullscreen],
[artifactPanelFullscreen, useFloatingArtifactPanel],
);
// The drawer's Radix dismiss layer only checks `event.key === 'Escape'`
// (no isComposing guard), so while composing in a panel input an Escape
@@ -18529,7 +18628,18 @@ export function App({
if (escArmedActionRef.current !== null) {
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();
live.handleCycleMode();
}
@@ -20001,6 +20111,34 @@ export function App({
onSelectTab: selectArtifactPanelTab,
onCloseTab: closeArtifactPanelTab,
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,
onWebPreviewChange: updateWebPreviewTab,
latestReviewAvailable: latestReviewChanges.length > 0,
@@ -23086,6 +23224,15 @@ export function App({
event.preventDefault();
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
// panel back to its drawer width. Without this the
// drawer's dismiss layer would close the panel instead.
@@ -23095,15 +23242,20 @@ export function App({
}}
>
<DrawerTitle className="sr-only">Right panel</DrawerTitle>
<WebShellCustomizationProvider value={customization}>
<ArtifactPanel
{...artifactPanelSharedProps}
onOpenTerminal={
webTerminalAvailable ? openTerminalTab : undefined
}
variant="drawer"
/>
</WebShellCustomizationProvider>
<div
className="flex min-h-0 flex-1"
onKeyDown={handleArtifactPanelSurfaceKeyDown}
>
<WebShellCustomizationProvider value={customization}>
<ArtifactPanel
{...artifactPanelSharedProps}
onOpenTerminal={
webTerminalAvailable ? openTerminalTab : undefined
}
variant="drawer"
/>
</WebShellCustomizationProvider>
</div>
</DrawerContent>
</Drawer>
)}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type Rule } from 'postcss';
import { FILES_MESSAGES_EN } from './components/artifacts/files-messages';
import { LIVE_MESSAGES_EN } from './live/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*\{/);
});
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', () => {
const unscoped: string[] = [];
postcss.parse(readInjectedCss()).walkRules((rule) => {
@@ -497,6 +531,16 @@ describe('build artifact — transcript entry (#11031)', () => {
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', () => {
// 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
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
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 { createRoot, type Root } from 'react-dom/client';
import { EditorView } from 'codemirror';
@@ -38,6 +38,7 @@ const {
readWorkspaceFile: vi.fn(),
readWorkspaceFileBytes: vi.fn(),
fileStat: vi.fn(),
dirList: vi.fn(),
};
return {
mockActions: {
@@ -48,6 +49,7 @@ const {
readFileBytes: vi.fn(),
readWorkspaceFile: vi.fn(),
stat: vi.fn(),
listDirectory: vi.fn(),
listScheduledTasks: vi.fn(),
updateScheduledTask: 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', () => ({
TerminalPanel: ({ terminalId }: { terminalId: string }) => (
<div data-testid="terminal-panel" data-terminal-id={terminalId} />
@@ -123,8 +189,14 @@ const { useArtifactWorkspaceTarget } = await import(
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
let latestArtifactWorkspaceTarget: ArtifactWorkspaceTarget | undefined;
function ArtifactWorkspaceTargetProbe({ revision }: { revision: number }) {
latestArtifactWorkspaceTarget = useArtifactWorkspaceTarget('/secondary');
function ArtifactWorkspaceTargetProbe({
revision,
workspaceCwd = '/secondary',
}: {
revision: number;
workspaceCwd?: string;
}) {
latestArtifactWorkspaceTarget = useArtifactWorkspaceTarget(workspaceCwd);
return <span data-revision={revision} />;
}
@@ -346,6 +418,13 @@ function scheduledTaskPanel(
}
afterEach(() => {
reloadPolicy.standalone = true;
reloadPolicy.token = undefined;
reloadPolicy.survives = true;
fileBrowserProps.loadError = undefined;
fileBrowserProps.sourceError = undefined;
fileBrowserProps.previewPath = undefined;
fileBrowserProps.loadAttempts = 0;
vi.unstubAllGlobals();
sideTaskPanelProps.current = undefined;
// The boundary matrix spies on DOMParser.prototype per row; restore it so
@@ -372,12 +451,14 @@ afterEach(() => {
mockWorkspaceActions.readFileBytes.mockReset();
mockWorkspaceActions.readWorkspaceFile.mockReset();
mockWorkspaceActions.stat.mockReset();
mockWorkspaceActions.listDirectory.mockReset();
mockWorkspaceActions.listScheduledTasks.mockReset();
mockWorkspaceActions.updateScheduledTask.mockReset();
mockWorkspaceActions.deleteScheduledTask.mockReset();
mockSecondaryWorkspaceActions.readWorkspaceFile.mockReset();
mockSecondaryWorkspaceActions.readWorkspaceFileBytes.mockReset();
mockSecondaryWorkspaceActions.fileStat.mockReset();
mockSecondaryWorkspaceActions.dirList.mockReset();
mockWorkspace.client.workspaceByCwd.mockClear();
mockWorkspace.capabilities.features = [];
latestArtifactWorkspaceTarget = undefined;
@@ -567,6 +648,96 @@ describe('ArtifactPanel web previews', () => {
});
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 () => {
let resolveRead:
| ((file: { content: string; truncated: boolean }) => void)
@@ -2192,6 +2363,496 @@ describe('ArtifactPanel add menu', () => {
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', () => {
const onOpenLatestReview = vi.fn();
const container = document.createElement('div');
@@ -1,3 +1,5 @@
import { useDaemonTargetOptional } from '../../config/daemon-target';
import { hasReloadSurvivableDaemonToken } from '../../config/daemon';
import type {
DaemonSessionArtifact,
SessionSource,
@@ -9,6 +11,7 @@ import type { ACPToolCall, TodoItem } from '../../adapters/types';
import type { WebShellRightPanelItem } from '../../customization';
import {
useConnection,
useWorkspace,
type DaemonSessionOwnerSnapshot,
type DaemonSessionActions,
type DaemonScheduledTask,
@@ -24,6 +27,7 @@ import {
ExpandIcon,
GaugeIcon,
GlobeIcon,
FolderIcon as FilesIcon,
ImageIcon,
LayersIcon,
WrenchIcon,
@@ -38,6 +42,8 @@ import {
} from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
import { Button } from '../ui/button';
import { ErrorBoundary } from '../ErrorBoundary';
import { RootErrorFallback } from '../RootErrorFallback';
import {
lazy,
Suspense,
@@ -60,7 +66,6 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime';
import { normalizeTextMediaType } from '../../utils/imageIngestion';
import { DialogShell } from '../dialogs/DialogShell';
import { FileTypeIcon } from '../FileTypeIcon';
import { ErrorBoundary } from '../ErrorBoundary';
import { isSafeHref, Markdown } from '../messages/Markdown';
import {
DropdownMenu,
@@ -125,6 +130,7 @@ import type { ContextUsageControls } from '../../hooks/useContextUsageControls';
import { ContextUsagePanel } from './ContextUsagePanel';
import {
useArtifactWorkspaceTarget,
resolveArtifactWorkspaceOwner,
type ArtifactWorkspaceActions,
} from './useArtifactWorkspaceTarget';
import {
@@ -132,6 +138,20 @@ import {
ShellTaskDetail,
} 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 MAX_REVIEW_SIDE_BY_SIDE_WIDTH = 700;
@@ -160,6 +180,13 @@ export type ImageTabSource = {
};
export type ArtifactPanelTab =
| {
id: string;
kind: 'files';
title: string;
workspaceCwd: string;
workspaceId?: string;
}
| (WebPreviewState & {
id: string;
kind: 'web_preview';
@@ -413,7 +440,7 @@ interface ArtifactPanelProps {
onSelectTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onOpenFilePreview: (
change: TurnOutputFileChange,
change: TurnOutputFileChange | string,
workspaceCwd?: string,
workspaceId?: string,
) => void;
@@ -430,6 +457,8 @@ interface ArtifactPanelProps {
* than for any trajectory tab at all.
*/
trajectoryTabId?: string;
onOpenFiles?: () => void;
onReferenceFile?: (path: string) => void;
onOpenWebPreview?: () => void;
onWebPreviewChange?: (tabId: string, state: WebPreviewState) => void;
items?: readonly WebShellRightPanelItem[];
@@ -489,6 +518,113 @@ interface ArtifactPanelProps {
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({
contextUsageControls,
artifacts,
@@ -510,6 +646,8 @@ export function ArtifactPanel({
onOpenTerminal,
onOpenTrajectory,
trajectoryTabId,
onOpenFiles,
onReferenceFile,
onOpenWebPreview,
onWebPreviewChange,
items = DEFAULT_RIGHT_PANEL_ITEMS,
@@ -541,6 +679,11 @@ export function ArtifactPanel({
onToggleFullscreen,
}: ArtifactPanelProps) {
const { t } = useI18n();
const workspace = useWorkspace();
const filesOwner = resolveArtifactWorkspaceOwner(
workspace.capabilities,
workspaceCwd,
);
const [sideTaskMenuOpen, setSideTaskMenuOpen] = useState(false);
const [previewAttachmentId, setPreviewAttachmentId] = useState<string>();
const sideTaskMenuCloseTimerRef = useRef<ReturnType<
@@ -606,9 +749,19 @@ export function ArtifactPanel({
items.includes('trajectory') &&
Boolean(onOpenTrajectory) &&
!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 =
Boolean(activeTab) &&
(showReviewMenuItem ||
(showFilesMenuItem ||
showReviewMenuItem ||
showSideTaskMenuItems ||
showTerminalMenuItem ||
showWebPreviewMenuItem ||
@@ -677,6 +830,8 @@ export function ArtifactPanel({
>
{getArtifactPanelTabKind(tab) === 'review' ? (
<TabReviewIcon />
) : tab.kind === 'files' ? (
<FilesIcon className={styles.tabIconSvg} />
) : tab.kind === 'workflow' ? (
<NetworkIcon
className={styles.tabIconSvg}
@@ -788,6 +943,14 @@ export function ArtifactPanel({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{showFilesMenuItem && (
<DropdownMenuItem onSelect={onOpenFiles}>
<FilesIcon />
<span className={styles.sideTaskListTitle}>
{t('files.title')}
</span>
</DropdownMenuItem>
)}
{showReviewMenuItem && (
<DropdownMenuItem
disabled={!latestReviewAvailable || !onOpenLatestReview}
@@ -890,7 +1053,9 @@ export function ArtifactPanel({
</div>
<div
className={`${styles.body} ${
activeTab?.kind === 'side_task' ? styles.bodySideTask : ''
activeTab?.kind === 'side_task' || activeTab?.kind === 'files'
? styles.bodySideTask
: ''
}`.trim()}
>
{tabs
@@ -907,6 +1072,23 @@ export function ArtifactPanel({
/>
</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
.filter((tab) => tab.kind === 'terminal')
.map((tab) => (
@@ -966,11 +1148,35 @@ export function ArtifactPanel({
<Skeleton className="h-4 w-3/5" />
</div>
) : activeTab?.kind === 'terminal' ||
activeTab?.kind === 'web_preview' ? null : !activeTab ? (
activeTab?.kind === 'web_preview' ||
activeTab?.kind === 'files' ? null : !activeTab ? (
<div
className={styles.emptyActions}
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') && (
<button
type="button"
@@ -3457,7 +3663,9 @@ function WorkspaceFilePreview({
previewKind,
previewOnly,
onLoadError,
sourceRenderer,
}: {
sourceRenderer?: (content: string) => ReactNode;
workspacePath: string;
artifactVersion?: string;
workspaceActions: ArtifactWorkspaceActions;
@@ -3586,6 +3794,7 @@ function WorkspaceFilePreview({
}
return (
<FileArtifactPreview
sourceRenderer={sourceRenderer}
workspacePath={workspacePath}
artifactVersion={artifactVersion}
workspaceActions={workspaceActions}
@@ -3822,6 +4031,7 @@ function useWorkspaceFileContent({
previewContent,
previewOnly,
onLoadError,
maxPreviewBytes,
}: {
workspacePath: string;
artifactVersion?: string;
@@ -3829,7 +4039,9 @@ function useWorkspaceFileContent({
previewContent?: string;
previewOnly?: boolean;
onLoadError?: (error: string) => void;
maxPreviewBytes?: number;
}) {
const { t } = useI18n();
const [content, setContent] = useState<string | null>(previewContent ?? null);
const [sizeBytes, setSizeBytes] = useState<number>();
const [error, setError] = useState<string | null>(null);
@@ -3847,6 +4059,8 @@ function useWorkspaceFileContent({
if (stat.type === 'directory') {
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, {
maxBytes: 256 * 1024,
});
@@ -3860,6 +4074,7 @@ function useWorkspaceFileContent({
{
statFile: (filePath) => workspaceActions.stat(filePath),
isCancelled: () => cancelled,
maxBytes: maxPreviewBytes,
},
);
return {
@@ -3890,6 +4105,8 @@ function useWorkspaceFileContent({
onLoadError,
workspaceActions,
workspacePath,
maxPreviewBytes,
t,
]);
return { content, error, sizeBytes };
@@ -3996,7 +4213,9 @@ function FileArtifactPreview({
previewContent,
previewOnly,
onLoadError,
sourceRenderer,
}: {
sourceRenderer?: (content: string) => ReactNode;
workspacePath: string;
artifactVersion?: string;
workspaceActions: ArtifactWorkspaceActions;
@@ -4013,6 +4232,7 @@ function FileArtifactPreview({
previewContent,
previewOnly,
onLoadError,
maxPreviewBytes: sourceRenderer ? 4 * 1024 * 1024 : undefined,
});
useEffect(() => {
@@ -4037,12 +4257,14 @@ function FileArtifactPreview({
return undefined;
}
return () => view.destroy();
}, [content]);
}, [content, sourceRenderer]);
return (
<div className={styles.filePreviewWrap}>
{content === null ? (
!error && <div className={styles.empty}>Loading file...</div>
) : sourceRenderer ? (
<div className={styles.codeMirrorFile}>{sourceRenderer(content)}</div>
) : (
<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) => ({
...(await original<Record<string, unknown>>()),
useWorkspace: () => ({ capabilities: undefined }),
useConnection: () => ({
...mock.connection,
workspaceCwd: mock.standalone ? undefined : mock.connection.workspaceCwd,
@@ -37,7 +38,8 @@ const target = {
workspaceId: 'owner-a',
actions: mock.actions,
};
vi.mock('./useArtifactWorkspaceTarget', () => ({
vi.mock('./useArtifactWorkspaceTarget', async (original) => ({
...(await original<typeof import('./useArtifactWorkspaceTarget')>()),
useArtifactWorkspaceTarget: (cwd: string) =>
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 {
useWorkspace,
useWorkspaceActions,
type DaemonDirectoryListing,
type DaemonFileStat,
type DaemonWorkspaceActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
@@ -15,6 +16,7 @@ export type ArtifactWorkspaceActions = Pick<
| 'readWorkspaceFile'
| 'readFileBytes'
| 'stat'
| 'listDirectory'
| 'listScheduledTasks'
| 'updateScheduledTask'
| 'deleteScheduledTask'
@@ -143,6 +145,16 @@ export function useArtifactWorkspaceTarget(
return current;
};
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) {
const current = requireOwner();
const result = await (current.primary
@@ -18,6 +18,11 @@ import {
PopoverTrigger,
} from './popover';
import { SelectTrigger } from './select';
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from './resizable';
import {
Table,
TableBody,
@@ -57,6 +62,9 @@ describe('React 18 ref compatibility', () => {
['DropdownMenuTrigger', DropdownMenuTrigger],
['DropdownMenuSubTrigger', DropdownMenuSubTrigger],
['SelectTrigger', SelectTrigger],
['ResizablePanelGroup', ResizablePanelGroup],
['ResizablePanel', ResizablePanel],
['ResizableHandle', ResizableHandle],
['Table', Table],
['TableHeader', TableHeader],
['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 =
| 'files'
| 'review'
| 'sideTask'
| 'terminal'
File diff suppressed because it is too large. Load diff
+6
View File
@@ -1,3 +1,7 @@
import {
FILES_MESSAGES_EN,
FILES_MESSAGES_ZH,
} from './components/artifacts/files-messages.js';
import {
createContext,
useContext,
@@ -1866,6 +1870,7 @@ const EN: Messages = {
'Which workspace this conversation belongs to is not known yet.',
'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.',
...FILES_MESSAGES_EN,
'rightPanel.add': 'Add panel',
'attachment.showPreview': 'Preview',
'attachment.showSource': 'Source',
@@ -6144,6 +6149,7 @@ const ZH: Messages = {
'localFiles.blocker.workspaceResolving': '尚不能确定该会话所属的工作区。',
'localFiles.blocker.unsupportedDaemon':
'该 daemon 未启用客户端文件桥(client_mcp_over_ws)。以 QWEN_SERVE_CLIENT_MCP_OVER_WS=1 启动 daemon 即可启用本地文件。',
...FILES_MESSAGES_ZH,
'rightPanel.add': '添加页签',
'attachment.showPreview': '预览',
'attachment.showSource': '源码',
+1
View File
@@ -427,6 +427,7 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
},
rightPanel: {
items: [
'files',
'review',
'sideTask',
'terminal',
+11 -2
View File
@@ -29,17 +29,19 @@
}
},
"files": [
"NOTICES.txt",
"dist/*.js",
"dist/types"
],
"scripts": {
"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:fix": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx --fix",
"format:check": "cd ../.. && prettier --experimental-cli --check packages/web-shell",
"typecheck": "tsc -p tsconfig.json --noEmit",
"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:ci": "vitest run --config vitest.config.ts",
"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:report": "playwright show-report client/e2e/playwright-report",
"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": {
"@codemirror/autocomplete": "^6.18.0",
@@ -77,6 +79,7 @@
"mermaid": "^11.15.0",
"radix-ui": "^1.6.2",
"react-markdown": "^9.0.0",
"react-resizable-panels": "4.14.3",
"rehype-katex": "^7.0.1",
"remark-cjk-friendly": "^2.3.1",
"remark-gfm": "^4.0.0",
@@ -92,7 +95,13 @@
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"bundledDevDependencies": [
"@pierre/diffs",
"@pierre/trees"
],
"devDependencies": {
"@pierre/diffs": "1.5.2",
"@pierre/trees": "1.0.0-beta.6",
"@playwright/test": "1.61.1",
"@qwen-code/acp-bridge": "file:../acp-bridge",
"@qwen-code/sdk": "file:../sdk-typescript",
@@ -36,6 +36,12 @@ try {
// membership check have to be reduced to that form first.
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
// way) anywhere in a declared `exports` target. Deliberately not `_`: Node
// resolves `./dist/_/*.js` normally.
+2
View File
@@ -127,6 +127,8 @@ function configureDevelopmentMiddleware(
export default defineConfig(({ command }) => ({
root: 'client',
// Worker-only imports otherwise trigger a dev-page reload on first preview.
optimizeDeps: { include: ['exceljs', 'ssf'] },
plugins: [
react(),
tailwindcss(),
+43 -32
View File
@@ -163,6 +163,10 @@ function injectCssModules(): Plugin {
const TRANSCRIPT_STUBS = new Map(
[
['./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.transcript-stub.ts',
@@ -256,38 +260,45 @@ export default defineConfig(({ mode }) => ({
fileName: (_format, entryName) => `${entryName}.js`,
},
rollupOptions: {
external: [
'react',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'react-dom',
'react-dom/client',
'radix-ui',
'lucide-react',
'class-variance-authority',
'clsx',
'tailwind-merge',
'vaul',
'@qwen-code/sdk',
/^@qwen-code\/sdk\//,
'@datafe-open/markdown-chart',
'@datafe-open/markdown-chart-echarts',
'@datafe-open/markdown-chart-react',
'echarts',
/^echarts\//,
'react-markdown',
'remark-cjk-friendly',
/^remark-cjk-friendly\//,
'remark-gfm',
'remark-math',
'rehype-katex',
'shiki',
'mermaid',
'katex',
/^katex\/(?!dist\/katex\.min\.css$)/,
'codemirror',
/^@codemirror\//,
],
external: (id, importer) => {
// Pierre requires Shiki 3/4; keep its copy inside the lazy preview bundle.
if (id === 'shiki') return !importer?.includes('/@pierre/');
return [
'react',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'react-dom',
'react-dom/client',
'radix-ui',
'lucide-react',
'class-variance-authority',
'clsx',
'tailwind-merge',
'vaul',
'@qwen-code/sdk',
/^@qwen-code\/sdk\//,
'@datafe-open/markdown-chart',
'@datafe-open/markdown-chart-echarts',
'@datafe-open/markdown-chart-react',
'echarts',
/^echarts\//,
'react-markdown',
'remark-cjk-friendly',
/^remark-cjk-friendly\//,
'remark-gfm',
'remark-math',
'rehype-katex',
'mermaid',
'katex',
/^katex\/(?!dist\/katex\.min\.css$)/,
'codemirror',
/^@codemirror\//,
].some((dependency) =>
typeof dependency === 'string'
? dependency === id
: dependency.test(id),
);
},
},
},
define: {
+263
View File
@@ -1578,6 +1578,9 @@ importers:
react-markdown:
specifier: ^9.0.0
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:
specifier: ^7.0.1
version: 7.0.1
@@ -1606,6 +1609,12 @@ importers:
specifier: ^1.1.2
version: 1.1.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
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':
specifier: 1.61.1
version: 1.61.1
@@ -3511,6 +3520,67 @@ packages:
'@paralleldrive/[email protected]':
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]':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -4450,21 +4520,53 @@ packages:
'@shikijs/[email protected]':
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]':
resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==}
'@shikijs/[email protected]':
resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==}
'@shikijs/[email protected]':
resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
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]':
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]':
resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==}
'@shikijs/[email protected]':
resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -8106,6 +8208,9 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
[email protected]:
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
peerDependencies:
@@ -8713,9 +8818,15 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
[email protected]:
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
[email protected]:
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
[email protected]:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
@@ -9104,6 +9215,14 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
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]:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -9300,6 +9419,12 @@ packages:
'@types/react':
optional: true
[email protected]:
resolution: {integrity: sha512-t/mJDWKBvc7sHcm/HJxJJKniqyQH6vK7oWyv8QODawYCcDwn0waBlGw0yFEwoMe6xPNKpbOFh2wN0yyina30ow==}
peerDependencies:
react: ^19.2.4
react-dom: ^19.2.4
[email protected]:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -9377,12 +9502,18 @@ packages:
[email protected]:
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
[email protected]:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
[email protected]:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
[email protected]:
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
[email protected]:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
[email protected]:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@@ -9642,6 +9773,10 @@ packages:
[email protected]:
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]:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -12657,6 +12792,51 @@ snapshots:
dependencies:
'@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]': {}
'@pkgjs/[email protected]':
@@ -13625,30 +13805,73 @@ snapshots:
'@types/hast': 3.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]':
dependencies:
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
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]':
dependencies:
'@shikijs/types': 1.29.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]':
dependencies:
'@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]':
dependencies:
'@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]':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/[email protected]':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/[email protected]': {}
'@simple-git/[email protected]': {}
@@ -18011,6 +18234,8 @@ snapshots:
dependencies:
yallist: 4.0.0
[email protected]: {}
[email protected]([email protected]):
dependencies:
react: 19.2.4
@@ -18919,12 +19144,20 @@ snapshots:
dependencies:
mimic-function: 5.0.1
[email protected]: {}
[email protected]:
dependencies:
emoji-regex-xs: 1.0.0
regex: 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]:
dependencies:
default-browser: 5.5.0
@@ -19299,6 +19532,12 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
dependencies:
preact: 11.0.0-beta.0
[email protected]: {}
[email protected]:
dependencies:
detect-libc: 2.1.2
@@ -19587,6 +19826,11 @@ snapshots:
optionalDependencies:
'@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]):
dependencies:
get-nonce: 1.0.1
@@ -19692,12 +19936,20 @@ snapshots:
regex: 5.1.1
regex-utilities: 2.3.0
[email protected]:
dependencies:
regex-utilities: 2.3.0
[email protected]: {}
[email protected]:
dependencies:
regex-utilities: 2.3.0
[email protected]:
dependencies:
regex-utilities: 2.3.0
[email protected]:
dependencies:
call-bind: 1.0.8
@@ -20136,6 +20388,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@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]:
dependencies:
es-errors: 1.3.0
+26 -2
View File
@@ -482,8 +482,8 @@ describe('post-merge push lane', () => {
'Run i18n check',
'Generate settings schema',
'Check settings schema is up-to-date',
'Generate VS Code companion notices',
'Check VS Code companion notices are up-to-date',
'Generate Web Shell and VS Code companion notices',
'Check Web Shell and VS Code companion notices are up-to-date',
'Check serve fast-path bundle closure',
'Check core subpath exports resolve',
'Run .github/scripts helper tests',
@@ -503,6 +503,30 @@ describe('post-merge push lane', () => {
)
.map((x) => x.name),
).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) {
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'];
function declarePackage(fixture, exports, packageFiles = files) {
write(fixture, 'NOTICES.txt', 'Bundled dependency licenses');
write(fixture, 'package.json', {
name: 'fixture-web-shell',
version: '0.0.0',
type: 'module',
files: packageFiles,
files: ['NOTICES.txt', ...packageFiles],
exports,
});
}
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', () => {
const result = runVerifier((fixture) => {
declarePackage(fixture, {