mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(web-shell): polish files and trim preview resources (#14143)
* fix(web-shell): polish files and trim preview resources * test(cli): observe pending child Hook promises * fix(web-shell): address Files browser review findings * fix(vscode): sync Pierre modification notices * chore: ignore the repo-local .pnpm-store fallback directory pnpm falls back to a project-local store at .pnpm-store/ when the global store directory is not writable (e.g. sandboxed CI runners). It is a regenerable cache that node_modules hard-links into; its thousands of untracked files otherwise dirty the working tree and fail deterministic workspace-clean verification. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): address Pierre preview and file display review findings - getPreviewLanguage folds case on the extension only, so README.MD and config.YML highlight while whole-name detector keys (Gemfile, BUILD) keep their case-sensitive behavior - formatFileName strips the full Default_Ignorable_Code_Point class so zero-advance Hangul fillers and variation selectors cannot desync the tree search projection from the rendered name - pin the previously unwitnessed pierre-build guards: redirected Shiki subpaths count toward the 4 MiB budget, getModuleInfo may return null for redirected ids, emitted assets are skipped, and entry-module resolveId calls carry no importer - pin the theme name->module mapping with exact token colors and give the formatFileName display sites discriminating coverage with repeated-space names --------- Co-authored-by: 钉萁 <[email protected]> Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
33 files changed
+1379
-76
No files matched your search
@@ -17,6 +17,9 @@
|
||||
# Dependency directory
|
||||
node_modules
|
||||
bower_components
|
||||
# pnpm falls back to a project-local store when the global store dir is not
|
||||
# writable (e.g. sandboxed CI runners); it is a regenerable cache.
|
||||
/.pnpm-store/
|
||||
|
||||
# Editors
|
||||
.idea
|
||||
|
||||
@@ -80,9 +80,10 @@ 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
|
||||
Source rendering is virtualized; theme changes update the renderer in place. Pierre
|
||||
uses a curated Shiki 4.5.0 entry with 33 supported language keys and two GitHub themes
|
||||
in the lazy preview chunk, while other renderers retain Shiki 1. See the
|
||||
[follow-up design](web-shell-files-followups.md) for resource selection and budgets; 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
|
||||
|
||||
@@ -57,8 +57,9 @@ React 18/19 peer 范围。Pierre 固定版本并作为构建期依赖,原生
|
||||
嵌入宿主和仅保存在内存中的凭据改为提供「关闭文件」,不重载宿主,也不把缓存的导入失败伪装成本地重试。
|
||||
Pierre 渲染错误在释放资源后进入局部边界;构造时即使用当前 Web Shell 主题,后续原地更新。浮动抽屉遍历 Shadow DOM 中的 Tab 焦点,
|
||||
防止 Radix 仅遍历普通 DOM 的焦点循环跳过文件树。
|
||||
源码采用虚拟滚动;切换主题原地更新渲染器。库构建将 Pierre 兼容的 Shiki 3/4
|
||||
依赖打包进延迟预览模块,其他渲染器保留现有 Shiki 1 依赖;超过 10,000 行或 1,048,576 字符时使用纯文本,以限制语法高亮开销。文件树的源码预览在读取内容前拒绝超过 4 MiB 的文件,分块回退读取也使用同一上限;
|
||||
源码采用虚拟滚动;切换主题原地更新渲染器。Pierre 使用裁剪后的 Shiki 4.5.0 入口,
|
||||
在延迟预览模块中支持 33 个语言键和两套 GitHub 主题;其他渲染器保留 Shiki 1。资源选择和
|
||||
体积预算见[后续设计](web-shell-files-followups.zh-CN.md);超过 10,000 行或 1,048,576 字符时使用纯文本,以限制语法高亮开销。文件树的源码预览在读取内容前拒绝超过 4 MiB 的文件,分块回退读取也使用同一上限;
|
||||
用户仍可通过文件菜单下载。Files 文案不进入只读 transcript 包;实际 Shadow DOM 文件树和文件菜单均有可访问名称。
|
||||
|
||||
文件右键菜单也为 `.xlsx` 提供「预览」,复用共享表格预览,在独立且绑定工作区的文件页签中打开。同一文件重复打开复用页签,返回「文件」保留目录树和内联选中。其他 Excel 格式沿用现有的仅下载行为。
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Web Shell Files follow-up fixes
|
||||
|
||||
[English](web-shell-files-followups.md) | [简体中文](web-shell-files-followups.zh-CN.md)
|
||||
|
||||
## Problem
|
||||
|
||||
This refines the [Files browser design](web-shell-file-browser.md).
|
||||
|
||||
The merged Files browser still displays control characters in Pierre-owned rows,
|
||||
ships the full highlighter grammar/theme graph, omits an inlined layout dependency
|
||||
from its license seeds, and has no action to close only the inline preview.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Add a pinned `patch-package` patch exposing a render-only `formatItemName` hook
|
||||
in Pierre Trees. Use a filename-specific formatter removing ANSI, control and
|
||||
Unicode format characters and the combining grapheme joiner, while
|
||||
preserving legal spaces, including repeated, leading/trailing and nonbreaking
|
||||
spaces. Search matches both this per-segment display projection and raw paths.
|
||||
Apply the formatter to ordinary,
|
||||
flattened and sticky rows and their accessible names; never mutate model paths.
|
||||
Raw paths continue to identify reads, downloads, references and clipboard copies.
|
||||
Two names with the same cleaned display remain distinct files. Remove the patch
|
||||
when the upstream package supplies an equivalent supported hook. Modified files
|
||||
carry banners and the patched package NOTICE feeds generated distribution notices.
|
||||
After changing the patch, reapply it and restart development with
|
||||
`npm run dev -- --force` from `packages/web-shell` to invalidate Vite prebundles.
|
||||
- Give Pierre a dedicated Shiki 4 build entry with explicit lazy loaders for common
|
||||
languages and only `github-light` / `github-dark`. Scope resolution to Pierre so
|
||||
existing Shiki 1 consumers keep their behavior. Unsupported languages fall back
|
||||
to readable plain text before rendering; retain existing large-file limits.
|
||||
Apply the same resource selection in development and app/library production
|
||||
builds. Exclude Pierre from Vite dependency prebundling so development uses
|
||||
the same adapter. `pierre-resources.ts` defines 33 allowed language keys;
|
||||
normalize `yml` to `yaml` and `kts` to `kotlin`. Guard rendered module code
|
||||
in this resource graph with a 4 MiB budget, excluding unrelated code in shared
|
||||
chunks, including redirected Shiki subpaths. Fail the budget check if Pierre is
|
||||
bundled without the curated adapters; transcript builds legitimately omit Pierre.
|
||||
Vitest applies the same adapter with Pierre dependencies inlined. Internal adapter
|
||||
declarations are not a public package surface; published declarations may depend
|
||||
only on declared runtime/peer packages or their declared type packages.
|
||||
Verify cold loading, common grammars, theme changes and fallback behavior.
|
||||
- Keep `react-resizable-panels` external in the published library, matching the
|
||||
existing UI dependencies. The npm dependency then supplies its own license;
|
||||
it is no longer an unattributed copy inside the library bundle.
|
||||
- Add Close file preview to the Files toolbar. Clear the selected file and restore
|
||||
a full-width tree even when it was hidden. Preserve loaded directories, expansion,
|
||||
filtering and tree state; selecting the same file again reopens it. Return
|
||||
keyboard focus to the filter only after the tree becomes visible.
|
||||
|
||||
## Scope
|
||||
|
||||
No Git integration, file editing, filesystem mutation, clipboard shell escaping or
|
||||
changes to the general Markdown/source renderers. Existing source paths remain the
|
||||
operation contract; copied paths are not shell commands. UI E2E uses a simulated
|
||||
daemon and does not establish real filesystem or Git backend behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
Use actual browser rows to check cleaned visible/accessible labels, original reads
|
||||
and clipboard values, colliding cleaned names, directory expansion and virtualization.
|
||||
Close the inline view with the tree visible and hidden, then reopen the same file.
|
||||
Exercise common syntax and unsupported-language fallback on cold load and switch
|
||||
between light/dark themes. Compare published library JavaScript footprint against
|
||||
the merged baseline, enforce the highlighter budget, regenerate notices, and run
|
||||
build, typecheck and targeted unit/browser tests. Audit the entire diff twice.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Web Shell 文件浏览器后续修复
|
||||
|
||||
[English](web-shell-files-followups.md) | [简体中文](web-shell-files-followups.zh-CN.md)
|
||||
|
||||
## 问题
|
||||
|
||||
本设计细化[文件浏览器设计](web-shell-file-browser.zh-CN.md)。
|
||||
|
||||
已合并的文件浏览器仍在 Pierre 树行中显示控制字符,携带完整高亮语法和主题资源图,
|
||||
内联布局依赖未纳入许可证清单生成范围,且没有仅关闭内联预览的操作。
|
||||
|
||||
## 决策
|
||||
|
||||
- 通过固定版本的 `patch-package` 补丁为 Pierre Trees 增加仅用于渲染的
|
||||
`formatItemName` 接口。文件名专用格式化移除 ANSI、控制字符、Unicode 格式字符及组合字素连接符,
|
||||
保留合法空格,包括连续空格、首尾空格和不换行空格。筛选同时匹配按段格式化的显示路径和原始路径。
|
||||
普通行、折叠行、固定行及其
|
||||
可访问名称均使用该接口;不改变模型路径。读取、下载、引用和复制路径继续使用真实路径。
|
||||
清洗后显示相同的两个名称仍表示不同文件。上游提供等价的受支持接口后移除补丁。
|
||||
修改文件带有声明,补丁中的包 NOTICE 自动进入生成的发行声明。修改补丁并重新应用后,
|
||||
从 `packages/web-shell` 执行 `npm run dev -- --force` 重启开发环境,清除 Vite 预构建缓存。
|
||||
- 为 Pierre 提供独立的 Shiki 4 构建入口,仅显式延迟加载常用语言和
|
||||
`github-light` / `github-dark` 两套主题。解析范围仅限 Pierre,现有 Shiki 1
|
||||
使用方保持原有行为。不支持的语言在渲染前回退为可读原文,保留已有大文件限制。
|
||||
开发环境、应用及库的生产构建均使用同一资源选择。将 Pierre 排除在 Vite 依赖预构建之外,
|
||||
确保开发环境使用相同适配层。`pierre-resources.ts` 定义 33 个允许的语言键,
|
||||
将 `yml` 归一化为 `yaml`、`kts` 归一化为 `kotlin`。对该资源图实际保留的模块代码
|
||||
设置 4 MiB 预算,包含重定向的 Shiki 子路径,不计入共享 chunk 中的其他代码。若构建包含
|
||||
Pierre 但缺少裁剪适配层则失败;transcript 构建不包含 Pierre 是合法情况。Vitest 同样应用适配层,
|
||||
并内联 Pierre 依赖。内部适配层声明不是公共包接口;发布的声明只能依赖已声明的运行时、peer
|
||||
或对应类型包。验证冷启动、常用语法、主题切换和回退行为。
|
||||
- 库发布时将 `react-resizable-panels` 设为 external,与现有 UI 依赖保持一致。
|
||||
npm 依赖自行携带许可证,库产物不再包含未署名的内联副本。
|
||||
- 在文件工具栏增加“关闭文件预览”。清除选中文件并恢复全宽文件树,包括树已隐藏的情况。
|
||||
保留已加载目录、展开态、筛选及树状态,再次选择同一文件可以重新打开。
|
||||
等待树变为可见后再将键盘焦点移回筛选框。
|
||||
|
||||
## 范围
|
||||
|
||||
不接入 Git,不编辑文件或修改文件系统,不做剪贴板 shell 转义,不改变通用 Markdown
|
||||
或源码渲染器。原始路径仍是操作契约,复制的路径不是 shell 命令。UI E2E 使用模拟 daemon,
|
||||
不能证明真实文件系统或 Git 后端操作。
|
||||
|
||||
## 验证
|
||||
|
||||
使用实际浏览器树行检查可见及可访问名称清洗、原始读取和复制值、清洗后重名、目录展开和
|
||||
虚拟滚动。分别在树可见和隐藏时关闭预览,然后重新打开同一文件。冷启动验证常用语法和
|
||||
未支持语言回退,切换浅深主题。将发布库的 JavaScript 体积与合并基线比较,执行高亮预算检查,
|
||||
重新生成许可证清单,运行构建、类型检查及相关单元和浏览器测试。完整检查两轮 diff。
|
||||
@@ -1773,6 +1773,7 @@ it.each([false, true])(
|
||||
original.getCatalog()!,
|
||||
);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, true, hooks);
|
||||
const childSession = children;
|
||||
try {
|
||||
expect(original.hasMountableToolResultHooks).toBe(true);
|
||||
await original.fire(
|
||||
@@ -1789,26 +1790,30 @@ it.each([false, true])(
|
||||
await original.close();
|
||||
await hooks.ensureReady();
|
||||
expect(hooks.mountHeld).toBe(false);
|
||||
const result = executeAgent(
|
||||
turn,
|
||||
call({
|
||||
description: 'audit',
|
||||
prompt: 'review',
|
||||
run_in_background: background,
|
||||
}),
|
||||
);
|
||||
if (!background) {
|
||||
await vi.waitFor(() =>
|
||||
expect(children.record('prompt:call-1')).toBeDefined(),
|
||||
);
|
||||
await children.settleFailed('prompt:call-1', {
|
||||
stopReason: 'creation_failed',
|
||||
reason: null,
|
||||
started: false,
|
||||
});
|
||||
}
|
||||
expect(JSON.stringify(await result)).not.toContain(mountRefusal);
|
||||
expect(children.record('prompt:call-1')).toBeDefined();
|
||||
const [result] = await Promise.all([
|
||||
executeAgent(
|
||||
turn,
|
||||
call({
|
||||
description: 'audit',
|
||||
prompt: 'review',
|
||||
run_in_background: background,
|
||||
}),
|
||||
),
|
||||
(async () => {
|
||||
if (background) return;
|
||||
await vi.waitFor(
|
||||
() => expect(childSession.record('prompt:call-1')).toBeDefined(),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await childSession.settleFailed('prompt:call-1', {
|
||||
stopReason: 'creation_failed',
|
||||
reason: null,
|
||||
started: false,
|
||||
});
|
||||
})(),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain(mountRefusal);
|
||||
expect(childSession.record('prompt:call-1')).toBeDefined();
|
||||
expect(
|
||||
broker.hookControl.mock.calls.filter(
|
||||
([operation]) => operation.kind === 'hook-execute',
|
||||
@@ -1898,27 +1903,32 @@ it.each(
|
||||
duration: 1,
|
||||
output: {},
|
||||
}));
|
||||
const childSession = children;
|
||||
try {
|
||||
const result = executeAgent(
|
||||
turn,
|
||||
call({
|
||||
description: 'audit',
|
||||
prompt: 'review',
|
||||
run_in_background: background,
|
||||
}),
|
||||
);
|
||||
if (!background) {
|
||||
await vi.waitFor(() =>
|
||||
expect(children.record('prompt:call-1')).toBeDefined(),
|
||||
);
|
||||
await children.settleFailed('prompt:call-1', {
|
||||
stopReason: 'creation_failed',
|
||||
reason: null,
|
||||
started: false,
|
||||
});
|
||||
}
|
||||
expect(JSON.stringify(await result)).not.toContain(mountRefusal);
|
||||
expect(children.record('prompt:call-1')).toBeDefined();
|
||||
const [result] = await Promise.all([
|
||||
executeAgent(
|
||||
turn,
|
||||
call({
|
||||
description: 'audit',
|
||||
prompt: 'review',
|
||||
run_in_background: background,
|
||||
}),
|
||||
),
|
||||
(async () => {
|
||||
if (background) return;
|
||||
await vi.waitFor(
|
||||
() => expect(childSession.record('prompt:call-1')).toBeDefined(),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await childSession.settleFailed('prompt:call-1', {
|
||||
stopReason: 'creation_failed',
|
||||
reason: null,
|
||||
started: false,
|
||||
});
|
||||
})(),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain(mountRefusal);
|
||||
expect(childSession.record('prompt:call-1')).toBeDefined();
|
||||
} finally {
|
||||
await turn.finish();
|
||||
await hooks.close();
|
||||
|
||||
+32
@@ -30546,6 +30546,10 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
Qwen Code modifications to @pierre/trees 1.0.0-beta.6: adds formatItemName for display-only labels and formatter-aware search; original path identity is preserved.
|
||||
|
||||
Modified files: dist/model/publicTypes.d.ts, dist/model/internalTypes.d.ts, dist/model/FileTreeController.js, dist/render/FileTree.js, dist/render/FileTreeView.js, dist/render/FileTreeView.d.ts.
|
||||
|
||||
============================================================
|
||||
[email protected]
|
||||
(https://github.com/preactjs/preact)
|
||||
@@ -30795,6 +30799,34 @@ CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
|
||||
|
||||
============================================================
|
||||
[email protected]
|
||||
(git+https://github.com/shikijs/shiki.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Pine Wu
|
||||
Copyright (c) 2023 Anthony Fu <https://github.com/antfu>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
[email protected]
|
||||
(https://github.com/markdown-it/markdown-it)
|
||||
|
||||
Generated
+32
@@ -2184,6 +2184,10 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
Qwen Code modifications to @pierre/trees 1.0.0-beta.6: adds formatItemName for display-only labels and formatter-aware search; original path identity is preserved.
|
||||
|
||||
Modified files: dist/model/publicTypes.d.ts, dist/model/internalTypes.d.ts, dist/model/FileTreeController.js, dist/render/FileTree.js, dist/render/FileTreeView.js, dist/render/FileTreeView.d.ts.
|
||||
|
||||
============================================================
|
||||
[email protected]
|
||||
(https://github.com/preactjs/preact)
|
||||
@@ -2433,3 +2437,31 @@ CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
|
||||
|
||||
============================================================
|
||||
[email protected]
|
||||
(git+https://github.com/shikijs/shiki.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Pine Wu
|
||||
Copyright (c) 2023 Anthony Fu <https://github.com/antfu>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ Local Control,以及多主机位置。面板开关遵循 `header.items`,并
|
||||
首条消息创建会话时迁移面板并删除草稿。草稿与会话各保留最近 20 份面板存档,
|
||||
草稿不会挤占会话的额度。
|
||||
|
||||
源码高亮支持 33 个常用语言键及 GitHub 浅色/深色主题,其他语言显示原文。
|
||||
语言清单维护于 [`pierre-resources.ts`](client/components/artifacts/pierre-resources.ts),
|
||||
选择策略与补丁移除条件见[后续设计](../../docs/design/web-shell-files-followups.zh-CN.md)。
|
||||
文件树移除名称中的控制字符,保留普通空格;筛选同时支持显示名称和真实路径。
|
||||
修改 Pierre 补丁并重新应用后,开发时执行 `npm run dev -- --force` 清除预构建缓存。
|
||||
|
||||
Excel(`.xlsx`)支持内联预览,也可通过右键「预览」在独立页签打开;重复打开同一文件复用页签,其他 Excel 格式仍仅支持下载。
|
||||
文件树源码预览在读取前限制为 4 MiB,更大的文件请通过右键菜单下载。
|
||||
导入失败仅在凭据可恢复的独立页面中提供重载;嵌入宿主或仅内存凭据时提供关闭文件,保留对话和宿主页面。
|
||||
|
||||
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
virtualizerCleanUp: vi.fn(),
|
||||
}));
|
||||
vi.mock('@pierre/diffs', () => ({
|
||||
getFiletypeFromFileName: () => 'typescript',
|
||||
DIFFS_TAG_NAME: 'diffs-container',
|
||||
Virtualizer: class {
|
||||
setup() {}
|
||||
@@ -129,7 +130,7 @@ it('recreates changed files with the current shell theme and pins the character
|
||||
await act(async () => root.render(render('first.ts', 'small')));
|
||||
expect(mocks.render).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
file: expect.objectContaining({ name: 'first.ts', lang: undefined }),
|
||||
file: expect.objectContaining({ name: 'first.ts', lang: 'typescript' }),
|
||||
}),
|
||||
);
|
||||
await act(async () =>
|
||||
@@ -156,7 +157,7 @@ it('recreates changed files with the current shell theme and pins the character
|
||||
file: expect.objectContaining({
|
||||
name: 'second.ts',
|
||||
contents: 'changed',
|
||||
lang: undefined,
|
||||
lang: 'typescript',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { DIFFS_TAG_NAME, VirtualizedFile, Virtualizer } from '@pierre/diffs';
|
||||
import { bundledLanguages } from './pierre-resources';
|
||||
import {
|
||||
DIFFS_TAG_NAME,
|
||||
getFiletypeFromFileName,
|
||||
VirtualizedFile,
|
||||
Virtualizer,
|
||||
} from '@pierre/diffs';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '../../themeContext';
|
||||
|
||||
/** @internal */
|
||||
export function getPreviewLanguage(
|
||||
path: string,
|
||||
): keyof typeof bundledLanguages | 'text' {
|
||||
// Fold case on the extension only: a whole-path toLowerCase() would break
|
||||
// the detector's uppercase-only whole-name keys (Gemfile, BUILD, ...).
|
||||
const detected = getFiletypeFromFileName(
|
||||
path.replace(/\.([^./\\]+)$/, (dot) => dot.toLowerCase()),
|
||||
);
|
||||
const language =
|
||||
detected === 'yml' ? 'yaml' : detected === 'kts' ? 'kotlin' : detected;
|
||||
return Object.hasOwn(bundledLanguages, language)
|
||||
? (language as keyof typeof bundledLanguages)
|
||||
: 'text';
|
||||
}
|
||||
|
||||
export default function PierreFilePreview({
|
||||
path,
|
||||
content,
|
||||
@@ -54,7 +76,8 @@ export default function PierreFilePreview({
|
||||
file: {
|
||||
name: path,
|
||||
contents: content,
|
||||
lang: content.length > 1024 * 1024 ? 'text' : undefined,
|
||||
lang:
|
||||
content.length > 1024 * 1024 ? 'text' : getPreviewLanguage(path),
|
||||
},
|
||||
fileContainer,
|
||||
});
|
||||
|
||||
@@ -34,8 +34,20 @@ vi.mock('../ui/resizable', () => ({
|
||||
),
|
||||
ResizableHandle: () => null,
|
||||
}));
|
||||
const treeOptions = vi.hoisted(() => ({
|
||||
current: undefined as
|
||||
| ConstructorParameters<typeof import('@pierre/trees').FileTree>[0]
|
||||
| undefined,
|
||||
}));
|
||||
vi.mock('@pierre/trees', () => ({
|
||||
FileTree: class {
|
||||
constructor(
|
||||
options: ConstructorParameters<
|
||||
typeof import('@pierre/trees').FileTree
|
||||
>[0],
|
||||
) {
|
||||
treeOptions.current = options;
|
||||
}
|
||||
render() {}
|
||||
getFileTreeContainer() {
|
||||
return undefined;
|
||||
@@ -43,6 +55,7 @@ vi.mock('@pierre/trees', () => ({
|
||||
subscribe() {
|
||||
return () => {};
|
||||
}
|
||||
batch() {}
|
||||
setSearch() {}
|
||||
cleanUp() {}
|
||||
},
|
||||
@@ -50,19 +63,28 @@ vi.mock('@pierre/trees', () => ({
|
||||
let root: Root | undefined;
|
||||
let container: HTMLDivElement;
|
||||
const upload = vi.fn();
|
||||
async function mount() {
|
||||
async function mount(
|
||||
workspaceCwd = '/workspace',
|
||||
rootListing: {
|
||||
entries: Array<{ name: string; kind: 'file' }>;
|
||||
truncated: boolean;
|
||||
} = {
|
||||
entries: [],
|
||||
truncated: false,
|
||||
},
|
||||
) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const actions = {
|
||||
listDirectory: vi.fn().mockResolvedValue({ entries: [], truncated: false }),
|
||||
listDirectory: vi.fn().mockResolvedValue(rootListing),
|
||||
uploadWorkspaceFile: upload,
|
||||
} as unknown as ArtifactWorkspaceActions;
|
||||
await act(async () =>
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<WorkspaceFileBrowser
|
||||
workspaceCwd="/workspace"
|
||||
workspaceCwd={workspaceCwd}
|
||||
actions={actions}
|
||||
renderPreview={() => null}
|
||||
onOpenPreview={() => {}}
|
||||
@@ -140,3 +162,17 @@ it('reports overflow once with the skipped filename and count', async () => {
|
||||
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
it('keeps repeated spaces in the workspace name shown in the header', async () => {
|
||||
await mount('/workspace/notes draft');
|
||||
expect(container.textContent).toContain('notes draft');
|
||||
});
|
||||
it('keeps repeated spaces in the selected file shown in the header', async () => {
|
||||
await mount('/workspace', {
|
||||
entries: [{ name: 'notes draft.md', kind: 'file' }],
|
||||
truncated: false,
|
||||
});
|
||||
act(() => treeOptions.current?.onSelectionChange?.(['notes draft.md']));
|
||||
const header = container.querySelector('header span')!;
|
||||
expect(header.textContent).toContain('notes draft.md');
|
||||
expect(header.getAttribute('title')).toContain('notes draft.md');
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatFileName } from './file-name-display';
|
||||
import { safeDisplayText } from '../../hooks/useAtMentionSources';
|
||||
import { usePanelRef } from 'react-resizable-panels';
|
||||
import { FileTree, type ContextMenuOpenContext } from '@pierre/trees';
|
||||
@@ -122,6 +123,8 @@ export function WorkspaceFileBrowser({
|
||||
const treePanelRef = usePanelRef();
|
||||
const contentPanelRef = usePanelRef();
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const filterRef = useRef<HTMLInputElement>(null);
|
||||
const restoreFilterFocusRef = useRef(false);
|
||||
const treeLabelRef = useRef(t('files.title'));
|
||||
treeLabelRef.current = t('files.title');
|
||||
const treeRef = useRef<FileTree | undefined>(undefined);
|
||||
@@ -164,13 +167,22 @@ export function WorkspaceFileBrowser({
|
||||
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%'),
|
||||
);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (hasPreview) contentPanelRef.current?.resize('70%');
|
||||
else treePanelRef.current?.resize('100%');
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [hasPreview, contentPanelRef]);
|
||||
}, [hasPreview, contentPanelRef, treePanelRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreview || !treeVisible || !restoreFilterFocusRef.current) return;
|
||||
restoreFilterFocusRef.current = false;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
filterRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [hasPreview, treeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -196,6 +208,7 @@ export function WorkspaceFileBrowser({
|
||||
initialExpansion: 'closed',
|
||||
flattenEmptyDirectories: false,
|
||||
icons: 'complete',
|
||||
formatItemName: formatFileName,
|
||||
unsafeCSS: `
|
||||
[data-file-tree-virtualized-scroll] { overflow: auto; }
|
||||
[data-file-tree-virtualized-list] {
|
||||
@@ -207,6 +220,7 @@ export function WorkspaceFileBrowser({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
[data-item-section="spacing"] { flex-shrink: 0; }
|
||||
[data-truncate-content] { white-space: pre; }
|
||||
`,
|
||||
// The host supplies the visible filter instead of Pierre's search input.
|
||||
search: false,
|
||||
@@ -644,7 +658,7 @@ export function WorkspaceFileBrowser({
|
||||
path
|
||||
? `${workspaceCwd.replace(separator === '\\' ? /[\\/]+$/ : /\/+$/, '')}${separator}${path.replaceAll('/', separator)}`
|
||||
: workspaceCwd;
|
||||
const workspaceName = safeDisplayText(
|
||||
const workspaceName = formatFileName(
|
||||
workspaceCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspaceCwd,
|
||||
);
|
||||
const absoluteMenuPath = absolutePath(menu?.path ?? '');
|
||||
@@ -689,7 +703,7 @@ export function WorkspaceFileBrowser({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48"
|
||||
aria-label={`${t('files.title')}: ${safeDisplayText(menu.path)}`}
|
||||
aria-label={`${t('files.title')}: ${formatFileName(menu.path)}`}
|
||||
aria-labelledby={undefined}
|
||||
data-file-tree-context-menu-root="true"
|
||||
onCloseAutoFocus={(event) => {
|
||||
@@ -961,10 +975,10 @@ export function WorkspaceFileBrowser({
|
||||
<header className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.path}
|
||||
title={safeDisplayText(absolutePath(selectedPath ?? ''))}
|
||||
title={formatFileName(absolutePath(selectedPath ?? ''))}
|
||||
>
|
||||
{workspaceName}
|
||||
{selectedPath && <> / {safeDisplayText(selectedPath)}</>}
|
||||
{selectedPath && <> / {formatFileName(selectedPath)}</>}
|
||||
</span>
|
||||
{canPreview && (
|
||||
<Button
|
||||
@@ -1023,6 +1037,22 @@ export function WorkspaceFileBrowser({
|
||||
<FolderIcon />
|
||||
</Button>
|
||||
)}
|
||||
{hasPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t('files.closePreview')}
|
||||
title={t('files.closePreview')}
|
||||
onClick={() => {
|
||||
restoreFilterFocusRef.current = true;
|
||||
treeRef.current?.getItem(selectedPath)?.deselect();
|
||||
treePanelRef.current?.expand();
|
||||
setSelectedPath(undefined);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
{actionError && (
|
||||
<div className={styles.notices} role="alert">
|
||||
@@ -1121,6 +1151,7 @@ export function WorkspaceFileBrowser({
|
||||
<div className={styles.search}>
|
||||
<SearchIcon aria-hidden />
|
||||
<Input
|
||||
ref={filterRef}
|
||||
data-workspace-file-filter
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
@@ -1195,7 +1226,7 @@ export function WorkspaceFileBrowser({
|
||||
<div key={directory.path}>
|
||||
<span>
|
||||
{directory.path
|
||||
? safeDisplayText(directory.path)
|
||||
? formatFileName(directory.path)
|
||||
: t('files.title')}
|
||||
:{' '}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import { FileTree } from '@pierre/trees';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { formatFileName } from './file-name-display';
|
||||
|
||||
it('removes invisible controls without normalizing legal filename spaces', () => {
|
||||
expect(
|
||||
formatFileName('a\u00ad\u2060\u034f\u180e\ufff9\u{e0041}\u202eb.ts'),
|
||||
).toBe('ab.ts');
|
||||
// Default-ignorable non-Cf characters: Hangul fillers and variation
|
||||
// selectors are zero-advance, so they must be stripped as well.
|
||||
expect(formatFileName('admin\u3164.ts')).toBe('admin.ts');
|
||||
expect(formatFileName('ad\ufe0fmin.ts')).toBe('admin.ts');
|
||||
expect(formatFileName(' notes draft\u00a0.md ')).toBe(
|
||||
' notes draft\u00a0.md ',
|
||||
);
|
||||
expect(formatFileName('\u001b[31mfile\u0001.ts')).toBe('file.ts');
|
||||
expect(formatFileName('\u202e')).toBe('[invalid]');
|
||||
});
|
||||
|
||||
it('formats real Pierre rows and search while retaining raw path identity', async () => {
|
||||
const controlled = 'control-\u202e\u0001\u2060file.ts';
|
||||
const nested = 'dir-\u2060/child notes.ts';
|
||||
const filler = 'admin\u3164.ts';
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const tree = new FileTree({
|
||||
paths: [controlled, 'control-file.ts', nested, filler],
|
||||
initialExpansion: 'open',
|
||||
flattenEmptyDirectories: false,
|
||||
formatItemName: formatFileName,
|
||||
search: false,
|
||||
});
|
||||
try {
|
||||
tree.render({ containerWrapper: host });
|
||||
const root = tree.getFileTreeContainer()!.shadowRoot!;
|
||||
await vi.waitFor(() =>
|
||||
expect(root.querySelectorAll('[role="treeitem"]')).toHaveLength(5),
|
||||
);
|
||||
const rows = [...root.querySelectorAll('[role="treeitem"]')];
|
||||
const row = rows.find(
|
||||
(item) => item.getAttribute('data-item-path') === controlled,
|
||||
)!;
|
||||
expect(row.getAttribute('aria-label')).toBe('control-file.ts');
|
||||
for (const character of ['\u202e', '\u0001', '\u2060'])
|
||||
expect(row.textContent).not.toContain(character);
|
||||
tree.setSearch('control-file.ts');
|
||||
expect(tree.getSearchMatchingPaths()).toEqual(
|
||||
expect.arrayContaining([controlled, 'control-file.ts']),
|
||||
);
|
||||
tree.setSearch('dir-/child notes.ts');
|
||||
expect(tree.getSearchMatchingPaths()).toEqual([nested]);
|
||||
tree.setSearch(controlled);
|
||||
expect(tree.getSearchMatchingPaths()).toEqual([controlled]);
|
||||
// The filler renders as 'admin.ts', so the visible-name query must match.
|
||||
tree.setSearch('admin.ts');
|
||||
expect(tree.getSearchMatchingPaths()).toEqual([filler]);
|
||||
} finally {
|
||||
tree.cleanUp();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sanitizeInsertText } from '../../hooks/useAtMentionSources';
|
||||
|
||||
export function formatFileName(name: string): string {
|
||||
return sanitizeInsertText(
|
||||
name.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, ''),
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const FILES_MESSAGES_EN = {
|
||||
'files.noMatches': 'No matches in loaded paths',
|
||||
'files.resizeTree': 'Resize file preview and tree',
|
||||
'files.toggleTree': 'Toggle file tree',
|
||||
'files.closePreview': 'Close file preview',
|
||||
'files.emptyDirectory': 'Empty folder',
|
||||
'files.truncated': 'Only the first 2,000 entries are shown',
|
||||
'files.loadFailed': 'File browser could not load',
|
||||
@@ -63,6 +64,7 @@ export const FILES_MESSAGES_ZH = {
|
||||
'files.noMatches': '已加载路径内无匹配',
|
||||
'files.resizeTree': '调整文件预览和目录树宽度',
|
||||
'files.toggleTree': '显示或隐藏文件树',
|
||||
'files.closePreview': '关闭文件预览',
|
||||
'files.emptyDirectory': '空目录',
|
||||
'files.truncated': '仅显示前 2,000 个条目',
|
||||
'files.loadFailed': '无法加载文件浏览器',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
createBundledHighlighter,
|
||||
createSingletonShorthands,
|
||||
} from 'shiki-files/core';
|
||||
import { createJavaScriptRegexEngine } from 'shiki-files/engine/javascript';
|
||||
import { bundledLanguages, bundledThemes } from './pierre-resources';
|
||||
|
||||
/** @internal */
|
||||
export {
|
||||
createCssVariablesTheme,
|
||||
getTokenStyleObject,
|
||||
stringifyTokenStyle,
|
||||
} from 'shiki-files/core';
|
||||
/** @internal */
|
||||
export { createOnigurumaEngine } from 'shiki-files/engine/oniguruma';
|
||||
/** @internal */
|
||||
export { createJavaScriptRegexEngine, bundledLanguages };
|
||||
|
||||
/** @internal */
|
||||
export const createHighlighter = createBundledHighlighter({
|
||||
langs: bundledLanguages,
|
||||
themes: bundledThemes,
|
||||
engine: createJavaScriptRegexEngine,
|
||||
});
|
||||
/** @internal */
|
||||
export const { codeToHtml } = createSingletonShorthands(createHighlighter);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { createHighlighter } from './pierre-highlighter';
|
||||
import { bundledLanguages, bundledThemes } from './pierre-resources';
|
||||
import { getPreviewLanguage } from './PierreFilePreview';
|
||||
import { getSharedHighlighter } from '@pierre/diffs';
|
||||
|
||||
it('pins both preview themes and the supported language count', () => {
|
||||
expect(Object.keys(bundledThemes).sort()).toEqual([
|
||||
'github-dark',
|
||||
'github-light',
|
||||
]);
|
||||
expect(Object.keys(bundledLanguages)).toHaveLength(33);
|
||||
});
|
||||
|
||||
it('uses the curated resources in the real Pierre test graph', async () => {
|
||||
const highlighter = await getSharedHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: ['typescript'],
|
||||
});
|
||||
expect(highlighter.getLoadedThemes()).toEqual(
|
||||
expect.arrayContaining(['github-dark', 'github-light']),
|
||||
);
|
||||
await expect(
|
||||
getSharedHighlighter({ themes: ['github-dark'], langs: ['haskell'] }),
|
||||
).rejects.toThrow('not found in bundled or custom languages');
|
||||
});
|
||||
|
||||
it('keeps common aliases highlighted and unsupported names readable as text', () => {
|
||||
for (const [path, language] of Object.entries({
|
||||
'app.ts': 'typescript',
|
||||
'component.tsx': 'tsx',
|
||||
'data.json': 'json',
|
||||
'README.md': 'markdown',
|
||||
'README.MD': 'markdown',
|
||||
'config.yml': 'yaml',
|
||||
'config.YML': 'yaml',
|
||||
'build.kts': 'kotlin',
|
||||
// Whole-name detector keys are case-sensitive and must survive the
|
||||
// extension-only fold: Gemfile is ruby, gemfile is not.
|
||||
Gemfile: 'ruby',
|
||||
gemfile: 'text',
|
||||
'run.sh': 'zsh',
|
||||
Dockerfile: 'dockerfile',
|
||||
'proof.lean': 'text',
|
||||
'unknown.extension': 'text',
|
||||
toString: 'text',
|
||||
}))
|
||||
expect(getPreviewLanguage(path)).toBe(language);
|
||||
const supported = getPreviewLanguage('app.ts');
|
||||
if (supported !== 'text')
|
||||
expect(typeof bundledLanguages[supported]).toBe('function');
|
||||
});
|
||||
|
||||
it('loads every selected grammar and both themes with the JavaScript engine', async () => {
|
||||
const highlighter = await createHighlighter({
|
||||
themes: Object.keys(bundledThemes),
|
||||
langs: [],
|
||||
});
|
||||
try {
|
||||
for (const [name, load] of Object.entries(bundledLanguages)) {
|
||||
await highlighter.loadLanguage(await load());
|
||||
for (const theme of Object.keys(bundledThemes)) {
|
||||
const { tokens } = highlighter.codeToTokens('const answer = 42;', {
|
||||
lang: name,
|
||||
theme,
|
||||
});
|
||||
expect(
|
||||
tokens
|
||||
.flat()
|
||||
.map((token) => token.content)
|
||||
.join(''),
|
||||
).toBe('const answer = 42;');
|
||||
expect(tokens.flat().every((token) => token.color)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
highlighter
|
||||
.codeToTokens('def unsupported := 42', {
|
||||
lang: 'text',
|
||||
theme: 'github-dark',
|
||||
})
|
||||
.tokens.flat()
|
||||
.map((token) => token.content)
|
||||
.join(''),
|
||||
).toBe('def unsupported := 42');
|
||||
// Pins the theme name -> module mapping: shiki assigns the theme fg to
|
||||
// every token when a theme has no rules, so every(color) cannot tell a
|
||||
// real theme from an empty or swapped one.
|
||||
const { tokens } = highlighter.codeToTokens('const answer = 42;', {
|
||||
lang: 'typescript',
|
||||
theme: 'github-dark',
|
||||
});
|
||||
expect([...new Set(tokens.flat().map((token) => token.color))]).toEqual([
|
||||
'#F97583',
|
||||
'#E1E4E8',
|
||||
'#79B8FF',
|
||||
]);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -0,0 +1,43 @@
|
||||
// Explicit imports keep unused Shiki grammars and themes out of both builds.
|
||||
/** @internal */
|
||||
export const bundledLanguages = {
|
||||
c: () => import('shiki-files/langs/c.mjs'),
|
||||
cpp: () => import('shiki-files/langs/cpp.mjs'),
|
||||
csharp: () => import('shiki-files/langs/csharp.mjs'),
|
||||
css: () => import('shiki-files/langs/css.mjs'),
|
||||
diff: () => import('shiki-files/langs/diff.mjs'),
|
||||
dockerfile: () => import('shiki-files/langs/dockerfile.mjs'),
|
||||
go: () => import('shiki-files/langs/go.mjs'),
|
||||
html: () => import('shiki-files/langs/html.mjs'),
|
||||
ini: () => import('shiki-files/langs/ini.mjs'),
|
||||
java: () => import('shiki-files/langs/java.mjs'),
|
||||
javascript: () => import('shiki-files/langs/javascript.mjs'),
|
||||
json: () => import('shiki-files/langs/json.mjs'),
|
||||
jsonc: () => import('shiki-files/langs/jsonc.mjs'),
|
||||
jsx: () => import('shiki-files/langs/jsx.mjs'),
|
||||
kotlin: () => import('shiki-files/langs/kotlin.mjs'),
|
||||
lua: () => import('shiki-files/langs/lua.mjs'),
|
||||
makefile: () => import('shiki-files/langs/makefile.mjs'),
|
||||
markdown: () => import('shiki-files/langs/markdown.mjs'),
|
||||
'objective-cpp': () => import('shiki-files/langs/objective-cpp.mjs'),
|
||||
php: () => import('shiki-files/langs/php.mjs'),
|
||||
python: () => import('shiki-files/langs/python.mjs'),
|
||||
ruby: () => import('shiki-files/langs/ruby.mjs'),
|
||||
rust: () => import('shiki-files/langs/rust.mjs'),
|
||||
shellscript: () => import('shiki-files/langs/shellscript.mjs'),
|
||||
sql: () => import('shiki-files/langs/sql.mjs'),
|
||||
swift: () => import('shiki-files/langs/swift.mjs'),
|
||||
toml: () => import('shiki-files/langs/toml.mjs'),
|
||||
tsx: () => import('shiki-files/langs/tsx.mjs'),
|
||||
typescript: () => import('shiki-files/langs/typescript.mjs'),
|
||||
vue: () => import('shiki-files/langs/vue.mjs'),
|
||||
xml: () => import('shiki-files/langs/xml.mjs'),
|
||||
yaml: () => import('shiki-files/langs/yaml.mjs'),
|
||||
zsh: () => import('shiki-files/langs/shellscript.mjs'),
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export const bundledThemes = {
|
||||
'github-light': () => import('shiki-files/themes/github-light.mjs'),
|
||||
'github-dark': () => import('shiki-files/themes/github-dark.mjs'),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { bundledThemes } from './pierre-resources';
|
||||
|
||||
/** @internal */
|
||||
export const pierreThemes = { getThemes: () => [] };
|
||||
/** @internal */
|
||||
export const shikiThemes = {
|
||||
getTheme(name: string) {
|
||||
if (!Object.hasOwn(bundledThemes, name)) return undefined;
|
||||
const load = bundledThemes[name as keyof typeof bundledThemes];
|
||||
return { name, load };
|
||||
},
|
||||
};
|
||||
@@ -36,7 +36,10 @@ function DrawerClose({
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
const DrawerOverlay: React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
> = React.forwardRef<
|
||||
React.ComponentRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentProps<typeof DrawerPrimitive.Overlay>
|
||||
>(function DrawerOverlay({ className, ...props }, ref) {
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
userTextEvent,
|
||||
} from './utils/mockDaemon';
|
||||
|
||||
const controlledName =
|
||||
'control-\u202e\u0001\u001b[31m\u00ad\u2060\u{e0041}file.ts';
|
||||
const controlledDirectory = 'folder-\u202e\u0001';
|
||||
|
||||
// UI integration uses a simulated daemon; it does not verify filesystem or Git operations.
|
||||
async function openFiles(
|
||||
page: Page,
|
||||
@@ -58,6 +62,11 @@ async function openFiles(
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await installMockDaemon(page, scenario, { baseURL });
|
||||
const contents: Record<string, string> = {
|
||||
[controlledName]: 'export const originalPath = true;',
|
||||
'control-file.ts': 'export const ordinaryPath = true;',
|
||||
'notes draft.md': '# Two spaces',
|
||||
[`${controlledDirectory}/child.ts`]: 'export const nestedOriginal = true;',
|
||||
'unsupported.lean': 'def unsupported := 42',
|
||||
'README.md':
|
||||
'# Workspace guide\n\nA rendered **Markdown** document.\n\n- Browse files\n- Read source\n',
|
||||
'src/app.ts':
|
||||
@@ -126,6 +135,7 @@ async function openFiles(
|
||||
path === '.'
|
||||
? [
|
||||
{ name: 'src', kind: 'directory', ignored: false },
|
||||
{ name: controlledDirectory, kind: 'directory', ignored: false },
|
||||
{ name: 'deep', kind: 'directory', ignored: false },
|
||||
{ name: 'empty', kind: 'directory', ignored: false },
|
||||
{ name: 'broken', kind: 'directory', ignored: false },
|
||||
@@ -136,6 +146,10 @@ async function openFiles(
|
||||
{ name: 'report.xlsx', kind: 'file', ignored: false },
|
||||
{ name: 'legacy.xls', kind: 'file', ignored: false },
|
||||
{ name: 'notes draft.md', kind: 'file', ignored: false },
|
||||
{ name: 'notes draft.md', kind: 'file', ignored: false },
|
||||
{ name: controlledName, kind: 'file', ignored: false },
|
||||
{ name: 'control-file.ts', kind: 'file', ignored: false },
|
||||
{ name: 'unsupported.lean', kind: 'file', ignored: false },
|
||||
...(rootUpdated
|
||||
? [{ name: 'new.txt', kind: 'file', ignored: false }]
|
||||
: []),
|
||||
@@ -165,7 +179,9 @@ async function openFiles(
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
: path === controlledDirectory
|
||||
? [{ name: 'child.ts', kind: 'file', ignored: false }]
|
||||
: [];
|
||||
await route.fulfill({
|
||||
json: { kind: 'list', path, entries, truncated: path === '.' },
|
||||
});
|
||||
@@ -768,6 +784,9 @@ test('virtualizes a long source file and keeps its final lines reachable', async
|
||||
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
||||
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
||||
await browser.getByRole('treeitem', { name: 'src', exact: true }).click();
|
||||
await browser
|
||||
.getByRole('textbox', { name: 'Filter files…', exact: true })
|
||||
.fill('src/long.ts');
|
||||
await browser.getByRole('treeitem', { name: 'long.ts', exact: true }).click();
|
||||
const source = browser.getByTestId('pierre-file-preview');
|
||||
await expect(source).toContainText('export const line0');
|
||||
@@ -868,6 +887,7 @@ test('offers file context actions, previews images and HTML, and references a fi
|
||||
const rightClick = async (name: string) => {
|
||||
await browser
|
||||
.getByRole('treeitem', { name, exact: true })
|
||||
.and(browser.locator(`[data-item-path="${name}"]`))
|
||||
.click({ button: 'right' });
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Copy relative path', exact: true }),
|
||||
@@ -953,7 +973,7 @@ test('offers file context actions, previews images and HTML, and references a fi
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(
|
||||
browser.getByRole('treeitem', { name: 'notes draft.md', exact: true }),
|
||||
browser.locator('[role="treeitem"][data-item-path="notes draft.md"]'),
|
||||
).toBeFocused();
|
||||
await rightClick('notes draft.md');
|
||||
await page.getByRole('menuitem', { name: 'Reference', exact: true }).click();
|
||||
@@ -1070,6 +1090,167 @@ test('scrolls the entire deep tree horizontally without squeezing names', async
|
||||
await expect(leaf).toBeFocused();
|
||||
});
|
||||
|
||||
test('@smoke cleans tree labels while keeping distinct raw file and directory paths', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
(window as unknown as { copied: string }).copied = text;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const { browser, directoryReads } = await openFiles(
|
||||
page,
|
||||
String(testInfo.project.use.baseURL),
|
||||
);
|
||||
const rows = browser.getByRole('treeitem', {
|
||||
name: 'control-file.ts',
|
||||
exact: true,
|
||||
});
|
||||
await expect(rows).toHaveCount(2);
|
||||
const identities = await rows.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute('data-item-path')),
|
||||
);
|
||||
expect(new Set(identities)).toEqual(
|
||||
new Set([controlledName, 'control-file.ts']),
|
||||
);
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const row = rows.nth(index);
|
||||
const path = identities[index]!;
|
||||
await expect(row).toHaveAttribute('aria-label', 'control-file.ts');
|
||||
for (const character of [
|
||||
'\u0001',
|
||||
'\u001b',
|
||||
'\u202e',
|
||||
'\u00ad',
|
||||
'\u2060',
|
||||
'\u{e0041}',
|
||||
])
|
||||
expect(await row.textContent()).not.toContain(character);
|
||||
await row.click();
|
||||
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
||||
path === controlledName ? 'originalPath' : 'ordinaryPath',
|
||||
);
|
||||
await row.click({ button: 'right' });
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Copy relative path', exact: true })
|
||||
.click();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { copied: string }).copied,
|
||||
),
|
||||
).toBe(path);
|
||||
await row.click({ button: 'right' });
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Copy absolute path', exact: true })
|
||||
.click();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { copied: string }).copied,
|
||||
),
|
||||
).toBe(`/tmp/qwen-web-shell-e2e/${path}`);
|
||||
}
|
||||
const filter = browser.getByRole('textbox', {
|
||||
name: 'Filter files…',
|
||||
exact: true,
|
||||
});
|
||||
await filter.fill('control-file.ts');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(browser.getByText('No matches in loaded paths')).toBeHidden();
|
||||
await filter.fill('notes draft.md');
|
||||
const spaced = browser.locator(
|
||||
'[role="treeitem"][data-item-path="notes draft.md"]',
|
||||
);
|
||||
await expect(spaced).toHaveAttribute('aria-label', 'notes draft.md');
|
||||
expect(
|
||||
await spaced
|
||||
.locator('[data-truncate-content="visible"]')
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.textContent).join('')),
|
||||
).toBe('notes draft.md');
|
||||
expect(
|
||||
await spaced
|
||||
.locator('[data-truncate-content="visible"]')
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).whiteSpace),
|
||||
).toBe('pre');
|
||||
await filter.fill('');
|
||||
await browser.getByRole('treeitem', { name: 'folder-', exact: true }).click();
|
||||
await browser
|
||||
.getByRole('treeitem', { name: 'child.ts', exact: true })
|
||||
.click();
|
||||
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
||||
'nestedOriginal',
|
||||
);
|
||||
expect(
|
||||
directoryReads.some((read) => read.endsWith(`path=${controlledDirectory}`)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('@smoke closes inline content to a full-width tree and reopens the same file', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const { browser, directoryReads } = await openFiles(
|
||||
page,
|
||||
String(testInfo.project.use.baseURL),
|
||||
);
|
||||
await browser.getByRole('treeitem', { name: 'folder-', exact: true }).click();
|
||||
const file = browser.getByRole('treeitem', { name: 'child.ts', exact: true });
|
||||
const tree = browser.getByTestId('workspace-file-sidebar');
|
||||
const content = browser.getByTestId('workspace-file-content');
|
||||
for (const hideTree of [false, true]) {
|
||||
await file.click();
|
||||
await expect(content).toBeVisible();
|
||||
if (hideTree)
|
||||
await browser
|
||||
.getByRole('button', { name: 'Toggle file tree', exact: true })
|
||||
.click();
|
||||
await browser
|
||||
.getByRole('button', { name: 'Close file preview', exact: true })
|
||||
.click();
|
||||
await expect(content).toBeHidden();
|
||||
await expect(browser.getByRole('separator')).toBeHidden();
|
||||
await expect(file).toBeVisible();
|
||||
await expect(
|
||||
browser.getByRole('textbox', { name: 'Filter files…', exact: true }),
|
||||
).toBeFocused();
|
||||
await expect
|
||||
.poll(async () => (await tree.boundingBox())!.width)
|
||||
.toBeCloseTo((await browser.boundingBox())!.width, 0);
|
||||
}
|
||||
expect(
|
||||
directoryReads.filter((read) =>
|
||||
read.endsWith(`path=${controlledDirectory}`),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
await file.click();
|
||||
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
||||
'nestedOriginal',
|
||||
);
|
||||
});
|
||||
|
||||
test('@smoke renders unsupported source as plain text on a cold load', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const { browser } = await openFiles(
|
||||
page,
|
||||
String(testInfo.project.use.baseURL),
|
||||
);
|
||||
await browser
|
||||
.getByRole('treeitem', { name: 'unsupported.lean', exact: true })
|
||||
.click();
|
||||
const source = browser.getByTestId('pierre-file-preview');
|
||||
await expect(source).toContainText('def unsupported := 42');
|
||||
await expect(source.locator('[data-line] span[style]')).toHaveCount(0);
|
||||
expect(await source.locator('[data-line]').count()).toBeGreaterThan(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('@smoke creates root files and folders with toolbar actions', async ({
|
||||
page,
|
||||
baseURL,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { pierreResources } from '../../pierre-build';
|
||||
|
||||
it('redirects only Pierre imports, including development query strings', () => {
|
||||
const { resolveId } = pierreResources();
|
||||
if (typeof resolveId !== 'function') throw new Error('Missing resolver');
|
||||
const resolve = (source: string, importer?: string) =>
|
||||
Reflect.apply(resolveId, undefined, [source, importer, {}]);
|
||||
for (const suffix of ['', '?v=cold-cache', '?v=cache#fragment']) {
|
||||
expect(
|
||||
resolve(
|
||||
'shiki',
|
||||
`/repo/node_modules/@pierre/diffs/dist/index.js${suffix}`,
|
||||
),
|
||||
).toMatch(/pierre-highlighter\.ts$/);
|
||||
for (const file of ['shared_highlighter', 'themeResolution']) {
|
||||
expect(
|
||||
resolve(
|
||||
'@pierre/theming/themes',
|
||||
`/repo/node_modules/@pierre/diffs/dist/highlighter/${file}.js${suffix}`,
|
||||
),
|
||||
).toMatch(/pierre-themes\.ts$/);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
resolve(
|
||||
'shiki',
|
||||
'C:/repo/node_modules/@pierre/diffs/dist/index.js?v=cache',
|
||||
),
|
||||
).toMatch(/pierre-highlighter\.ts$/);
|
||||
// Rollup calls resolveId without an importer for entry modules.
|
||||
expect(resolve('shiki')).toBeUndefined();
|
||||
expect(resolve('shiki', '/repo/client/code-highlighter.ts')).toBeUndefined();
|
||||
for (const subpath of ['wasm', 'core', 'engine/javascript', 'textmate']) {
|
||||
expect(
|
||||
resolve(
|
||||
`shiki/${subpath}`,
|
||||
'/repo/node_modules/@pierre/diffs/dist/highlighter/shared_highlighter.js',
|
||||
),
|
||||
).toContain('shiki-files');
|
||||
expect(
|
||||
resolve(`shiki/${subpath}`, '/repo/client/code-highlighter.ts'),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
resolve(
|
||||
'@pierre/theming/themes',
|
||||
'/repo/node_modules/@pierre/diffs/dist/highlighter/registerCustomTheme.js',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('counts static and lazy descendants and requires an adapter for Pierre', () => {
|
||||
const entry = '/repo/client/components/artifacts/pierre-highlighter.ts';
|
||||
const resources = '/repo/client/components/artifacts/pierre-resources.ts';
|
||||
const grammar = '/repo/node_modules/shiki-files/langs/typescript.mjs';
|
||||
const theme = '/repo/node_modules/shiki-files/themes/github-light.mjs';
|
||||
const pierre = '/repo/node_modules/@pierre/diffs/dist/index.js';
|
||||
const graph: Record<
|
||||
string,
|
||||
{ importedIds: string[]; dynamicallyImportedIds: string[] }
|
||||
> = {
|
||||
[entry]: { importedIds: [resources], dynamicallyImportedIds: [] },
|
||||
[resources]: { importedIds: [theme], dynamicallyImportedIds: [grammar] },
|
||||
[grammar]: { importedIds: [entry], dynamicallyImportedIds: [] },
|
||||
[theme]: { importedIds: [], dynamicallyImportedIds: [] },
|
||||
};
|
||||
const check = (
|
||||
ids: string[],
|
||||
grammarBytes = 100,
|
||||
themeBytes = 100,
|
||||
redirectedBytes = 0,
|
||||
) => {
|
||||
// resolveId and generateBundle must run on one plugin instance:
|
||||
// redirectedModules is populated by resolveId and read by generateBundle.
|
||||
const plugin = pierreResources();
|
||||
const { resolveId, generateBundle } = plugin;
|
||||
if (typeof resolveId !== 'function' || typeof generateBundle !== 'function')
|
||||
throw new Error('Missing hooks');
|
||||
const modules: Record<string, { renderedLength: number }> = {
|
||||
[grammar]: { renderedLength: grammarBytes },
|
||||
[theme]: { renderedLength: themeBytes },
|
||||
};
|
||||
if (redirectedBytes > 0) {
|
||||
const redirected = Reflect.apply(resolveId, undefined, [
|
||||
'shiki/wasm',
|
||||
pierre,
|
||||
{},
|
||||
]);
|
||||
if (typeof redirected !== 'string') throw new Error('Missing redirect');
|
||||
// The redirected id is absent from the graph, so the stub's
|
||||
// getModuleInfo returns undefined for it, exercising the
|
||||
// module?.importedIds ?? [] tolerance.
|
||||
modules[redirected] = { renderedLength: redirectedBytes };
|
||||
}
|
||||
Reflect.apply(
|
||||
generateBundle,
|
||||
{
|
||||
getModuleIds: () => ids,
|
||||
getModuleInfo: (id: string) => graph[id],
|
||||
error: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
info: () => {},
|
||||
},
|
||||
[
|
||||
{},
|
||||
{
|
||||
entry: {
|
||||
type: 'chunk',
|
||||
modules: { [entry]: { renderedLength: 100 } },
|
||||
},
|
||||
lazy: {
|
||||
type: 'chunk',
|
||||
modules,
|
||||
},
|
||||
},
|
||||
false,
|
||||
],
|
||||
);
|
||||
};
|
||||
expect(() => check([entry, pierre])).not.toThrow();
|
||||
expect(() => check([entry, pierre], 4 * 1024 * 1024)).toThrow('4 MiB budget');
|
||||
expect(() => check([entry, pierre], 100, 4 * 1024 * 1024)).toThrow(
|
||||
'4 MiB budget',
|
||||
);
|
||||
expect(() => check([entry, pierre], 100, 100, 100)).not.toThrow();
|
||||
expect(() => check([entry, pierre], 100, 100, 4 * 1024 * 1024)).toThrow(
|
||||
'4 MiB budget',
|
||||
);
|
||||
expect(() => check([pierre])).toThrow('no Pierre highlighter resource');
|
||||
expect(() => check(['/repo/client/transcript.ts'])).not.toThrow();
|
||||
});
|
||||
|
||||
it('budgets resource modules without counting unrelated shared chunk code', () => {
|
||||
const { generateBundle } = pierreResources();
|
||||
if (typeof generateBundle !== 'function')
|
||||
throw new Error('Missing budget check');
|
||||
const entry = '/repo/client/components/artifacts/pierre-highlighter.ts';
|
||||
const check = (resourceBytes: number) =>
|
||||
Reflect.apply(
|
||||
generateBundle,
|
||||
{
|
||||
getModuleIds: () => [entry],
|
||||
getModuleInfo: () => ({ importedIds: [], dynamicallyImportedIds: [] }),
|
||||
error: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
info: () => {},
|
||||
},
|
||||
[
|
||||
{},
|
||||
{
|
||||
shared: {
|
||||
type: 'chunk',
|
||||
code: 'x'.repeat(5 * 1024 * 1024),
|
||||
modules: {
|
||||
[entry]: { renderedLength: resourceBytes },
|
||||
'/repo/client/App.tsx': { renderedLength: 5 * 1024 * 1024 },
|
||||
},
|
||||
},
|
||||
// A real build emits assets next to chunks; they carry no modules.
|
||||
'favicon.png': { type: 'asset' },
|
||||
},
|
||||
false,
|
||||
],
|
||||
);
|
||||
expect(() => check(100)).not.toThrow();
|
||||
expect(() => check(4 * 1024 * 1024 + 1)).toThrow('4 MiB budget');
|
||||
});
|
||||
@@ -31,7 +31,8 @@
|
||||
"files": [
|
||||
"NOTICES.txt",
|
||||
"dist/*.js",
|
||||
"dist/types"
|
||||
"dist/types",
|
||||
"!dist/types/components/artifacts/pierre-*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@types/hast": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -97,7 +99,8 @@
|
||||
},
|
||||
"bundledDevDependencies": [
|
||||
"@pierre/diffs",
|
||||
"@pierre/trees"
|
||||
"@pierre/trees",
|
||||
"shiki-files"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@pierre/diffs": "1.5.2",
|
||||
@@ -106,7 +109,6 @@
|
||||
"@qwen-code/acp-bridge": "file:../acp-bridge",
|
||||
"@qwen-code/sdk": "file:../sdk-typescript",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/hast": "^3.0.5",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -120,6 +122,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"shiki-files": "npm:[email protected]",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { normalizePath, type Plugin } from 'vite';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function pierreResources(): Plugin {
|
||||
const redirectedModules = new Set<string>();
|
||||
return {
|
||||
name: 'web-shell-pierre-resources',
|
||||
enforce: 'pre',
|
||||
resolveId(source, importer) {
|
||||
if (!importer || !normalizePath(importer).includes('/@pierre/diffs/'))
|
||||
return undefined;
|
||||
if (source === 'shiki')
|
||||
return resolve(
|
||||
__dirname,
|
||||
'client/components/artifacts/pierre-highlighter.ts',
|
||||
);
|
||||
if (source.startsWith('shiki/')) {
|
||||
const id = require.resolve(source.replace('shiki/', 'shiki-files/'));
|
||||
redirectedModules.add(id);
|
||||
return id;
|
||||
}
|
||||
if (
|
||||
source === '@pierre/theming/themes' &&
|
||||
/\/(?:shared_highlighter|themeResolution)\.js(?:[?#].*)?$/.test(
|
||||
normalizePath(importer),
|
||||
)
|
||||
)
|
||||
return resolve(
|
||||
__dirname,
|
||||
'client/components/artifacts/pierre-themes.ts',
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
generateBundle(_options, bundle) {
|
||||
const resources = new Set<string>();
|
||||
let pierreBundled = false;
|
||||
const visit = (id: string) => {
|
||||
if (resources.has(id)) return;
|
||||
resources.add(id);
|
||||
const module = this.getModuleInfo(id);
|
||||
for (const dependency of [
|
||||
...(module?.importedIds ?? []),
|
||||
...(module?.dynamicallyImportedIds ?? []),
|
||||
])
|
||||
visit(dependency);
|
||||
};
|
||||
for (const id of this.getModuleIds()) {
|
||||
const normalized = normalizePath(id);
|
||||
if (normalized.includes('/@pierre/diffs/')) pierreBundled = true;
|
||||
if (/\/pierre-(?:highlighter|themes)\.ts$/.test(normalized)) visit(id);
|
||||
}
|
||||
if (pierreBundled && resources.size === 0)
|
||||
this.error(
|
||||
'@pierre/diffs is bundled but no Pierre highlighter resource module resolved',
|
||||
);
|
||||
for (const id of redirectedModules) visit(id);
|
||||
// Shared chunks can also contain unrelated application code.
|
||||
const bytes = Object.values(bundle).reduce((size, item) => {
|
||||
if (item.type !== 'chunk') return size;
|
||||
return (
|
||||
size +
|
||||
Object.entries(item.modules).reduce(
|
||||
(total, [id, module]) =>
|
||||
total + (resources.has(id) ? module.renderedLength : 0),
|
||||
0,
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
if (bytes > 4 * 1024 * 1024)
|
||||
this.error(
|
||||
`Pierre highlighter exceeds the 4 MiB budget: ${bytes} bytes`,
|
||||
);
|
||||
this.info(
|
||||
`Pierre highlighter resources: ${bytes} rendered module bytes (4 MiB budget)`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isBuiltin } from 'node:module';
|
||||
import pkg from '../package.json' with { type: 'json' };
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -141,19 +142,32 @@ for (const [key, entry] of entryPoints) {
|
||||
}
|
||||
}
|
||||
|
||||
// Declarations ship verbatim, so they must not import through the alias that
|
||||
// only this repository resolves.
|
||||
// Published declarations must resolve without this repository's dev dependencies.
|
||||
const typeDependencies = new Set([
|
||||
pkg.name,
|
||||
...Object.keys(pkg.dependencies ?? {}),
|
||||
...Object.keys(pkg.peerDependencies ?? {}),
|
||||
]);
|
||||
const typesDir = join(root, 'dist/types');
|
||||
if (existsSync(typesDir)) {
|
||||
for (const name of readdirSync(typesDir, { recursive: true })) {
|
||||
if (!name.endsWith('.d.ts')) continue;
|
||||
if (packed && !packed.has(packPath(join(typesDir, name)))) continue;
|
||||
const source = readFileSync(join(typesDir, name), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/.*$/gm, '');
|
||||
for (const [, specifier] of source.matchAll(
|
||||
/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g,
|
||||
)) {
|
||||
if (specifier.startsWith('@/')) {
|
||||
if (specifier.startsWith('.') || isBuiltin(specifier)) continue;
|
||||
const dependency = specifier.startsWith('@')
|
||||
? specifier.split('/').slice(0, 2).join('/')
|
||||
: specifier.split('/')[0];
|
||||
const typesPackage = `@types/${dependency.replace(/^@/, '').replace('/', '__')}`;
|
||||
if (
|
||||
!typeDependencies.has(dependency) &&
|
||||
!typeDependencies.has(typesPackage)
|
||||
) {
|
||||
problems.push(`dist/types/${name} imports the repo-only ${specifier}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"stripInternal": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"declarationDir": "dist/types",
|
||||
"noEmit": false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pierreResources } from './pierre-build';
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vite';
|
||||
import type { PreviewServer, ProxyOptions, ViteDevServer } from 'vite';
|
||||
@@ -128,8 +129,18 @@ function configureDevelopmentMiddleware(
|
||||
export default defineConfig(({ command }) => ({
|
||||
root: 'client',
|
||||
// Worker-only imports otherwise trigger a dev-page reload on first preview.
|
||||
optimizeDeps: { include: ['exceljs', 'ssf'] },
|
||||
optimizeDeps: {
|
||||
exclude: ['@pierre/diffs', 'shiki/wasm'],
|
||||
include: [
|
||||
'exceljs',
|
||||
'ssf',
|
||||
'shiki-files/core',
|
||||
'shiki-files/engine/javascript',
|
||||
'shiki-files/engine/oniguruma',
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
pierreResources(),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pierreResources } from './pierre-build';
|
||||
import { defineConfig, normalizePath, type Plugin } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
@@ -231,6 +232,7 @@ function stubTranscriptModules(): Plugin {
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
pierreResources(),
|
||||
...(mode === 'transcript' ? [stubTranscriptModules()] : []),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
@@ -287,6 +289,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'clsx',
|
||||
'tailwind-merge',
|
||||
'vaul',
|
||||
'react-resizable-panels',
|
||||
'@qwen-code/sdk',
|
||||
/^@qwen-code\/sdk\//,
|
||||
'@datafe-open/markdown-chart',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'node:path';
|
||||
import { pierreResources } from './pierre-build';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [pierreResources()],
|
||||
root: 'client',
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -17,6 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
server: { deps: { inline: ['@pierre/diffs'] } },
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
// Same shared-ECS ceilings as packages/cli: the jsdom component tests
|
||||
// here were the bulk of the release failures at vitest's 5s default.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
diff --git a/node_modules/@pierre/trees/NOTICE.md b/node_modules/@pierre/trees/NOTICE.md
|
||||
--- a/node_modules/@pierre/trees/NOTICE.md
|
||||
+++ b/node_modules/@pierre/trees/NOTICE.md
|
||||
@@ -33,3 +33,7 @@
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
+
|
||||
+Qwen Code modifications to @pierre/trees 1.0.0-beta.6: adds formatItemName for display-only labels and formatter-aware search; original path identity is preserved.
|
||||
+
|
||||
+Modified files: dist/model/publicTypes.d.ts, dist/model/internalTypes.d.ts, dist/model/FileTreeController.js, dist/render/FileTree.js, dist/render/FileTreeView.js, dist/render/FileTreeView.d.ts.
|
||||
diff --git a/node_modules/@pierre/trees/dist/model/publicTypes.d.ts b/node_modules/@pierre/trees/dist/model/publicTypes.d.ts
|
||||
--- a/node_modules/@pierre/trees/dist/model/publicTypes.d.ts
|
||||
+++ b/node_modules/@pierre/trees/dist/model/publicTypes.d.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
+/*! Modified by Qwen Code: display-only formatItemName and search; see package NOTICE. */
|
||||
import { FileTreeIcons, RemappedIcon } from "../iconConfig.js";
|
||||
import { ContextMenuAnchorRect, GitStatusEntry } from "../publicTypes.js";
|
||||
import { FileTreeDensity } from "./density.js";
|
||||
@@ -58,6 +59,7 @@
|
||||
dragAndDrop?: boolean | FileTreeDragAndDropConfig;
|
||||
fileTreeSearchMode?: FileTreeSearchMode;
|
||||
initialSearchQuery?: string | null;
|
||||
+ formatItemName?: (name: string) => string;
|
||||
initialSelectedPaths?: readonly FileTreePublicId[];
|
||||
onSearchChange?: FileTreeSearchChangeListener;
|
||||
renaming?: boolean | FileTreeRenamingConfig;
|
||||
diff --git a/node_modules/@pierre/trees/dist/model/internalTypes.d.ts b/node_modules/@pierre/trees/dist/model/internalTypes.d.ts
|
||||
--- a/node_modules/@pierre/trees/dist/model/internalTypes.d.ts
|
||||
+++ b/node_modules/@pierre/trees/dist/model/internalTypes.d.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
+/*! Modified by Qwen Code: display-only formatItemName and search; see package NOTICE. */
|
||||
import { FileTreeIcons } from "../iconConfig.js";
|
||||
import { GitStatus } from "../publicTypes.js";
|
||||
import { FileTreeCompositionOptions, FileTreePublicId, FileTreeRenderOptions, FileTreeRowDecorationRenderer, FileTreeScrollOffset, FileTreeSearchBlurBehavior, FileTreeVisibleRow } from "./publicTypes.js";
|
||||
@@ -45,6 +46,7 @@
|
||||
initialViewportHeight?: number;
|
||||
instanceId?: string;
|
||||
renamingEnabled?: boolean;
|
||||
+ formatItemName?: (name: string) => string;
|
||||
renderRowDecoration?: FileTreeRowDecorationRenderer;
|
||||
searchBlurBehavior?: FileTreeSearchBlurBehavior;
|
||||
searchEnabled?: boolean;
|
||||
diff --git a/node_modules/@pierre/trees/dist/model/FileTreeController.js b/node_modules/@pierre/trees/dist/model/FileTreeController.js
|
||||
--- a/node_modules/@pierre/trees/dist/model/FileTreeController.js
|
||||
+++ b/node_modules/@pierre/trees/dist/model/FileTreeController.js
|
||||
@@ -1,3 +1,4 @@
|
||||
+/*! Modified by Qwen Code: display-only formatItemName and search; see package NOTICE. */
|
||||
import { PathStore } from "../path-store/src/store.js";
|
||||
import "../path-store/src/index.js";
|
||||
import { renameFileTreePaths } from "../utils/renameFileTreePaths.js";
|
||||
@@ -100,6 +101,7 @@
|
||||
#onRename;
|
||||
#onRenameError;
|
||||
#onSearchChange;
|
||||
+ #formatItemName;
|
||||
#projectionPaths = [];
|
||||
#projectionPosInSetByIndex = /* @__PURE__ */ new Int32Array(0);
|
||||
#projectionSetSizeByIndex = /* @__PURE__ */ new Int32Array(0);
|
||||
@@ -128,7 +130,7 @@
|
||||
#visibleCount = 0;
|
||||
#unsubscribe;
|
||||
constructor(options) {
|
||||
- const { dragAndDrop, fileTreeSearchMode, initialSearchQuery, initialSelectedPaths, renaming, onSearchChange, paths, preparedInput, ...baseOptions } = options;
|
||||
+ const { dragAndDrop, fileTreeSearchMode, initialSearchQuery, initialSelectedPaths, renaming, onSearchChange, formatItemName, paths, preparedInput, ...baseOptions } = options;
|
||||
const resolvedInput = resolveFileTreeInput({
|
||||
paths,
|
||||
preparedInput
|
||||
@@ -142,6 +144,7 @@
|
||||
this.#onRename = renaming.onRename;
|
||||
}
|
||||
this.#onSearchChange = onSearchChange;
|
||||
+ this.#formatItemName = formatItemName;
|
||||
this.#searchMode = fileTreeSearchMode ?? "hide-non-matches";
|
||||
this.#store = this.#createStore(resolvedInput.paths, resolvedInput.preparedInput);
|
||||
const resolvedInitialSelectedPaths = initialSelectedPaths?.map((path) => this.#resolveSelectionPath(path)).filter((resolved) => resolved != null) ?? [];
|
||||
@@ -889,9 +892,12 @@
|
||||
this.#knownPaths = [...knownPaths].sort();
|
||||
return this.#knownPaths;
|
||||
}
|
||||
+ #formatSearchPath(path) {
|
||||
+ return toLowerCaseSearchPath(this.#formatItemName == null ? path : path.split("/").map(segment => segment === "" ? "" : this.#formatItemName(segment)).join("/"));
|
||||
+ }
|
||||
#getListedPathsLowerCase() {
|
||||
if (this.#listedPathsLowerCase != null) return this.#listedPathsLowerCase;
|
||||
- this.#listedPathsLowerCase = this.#getListedPaths().map(toLowerCaseSearchPath);
|
||||
+ this.#listedPathsLowerCase = this.#getListedPaths().map(path => this.#formatSearchPath(path));
|
||||
return this.#listedPathsLowerCase;
|
||||
}
|
||||
#getAllKnownDirectoryPaths() {
|
||||
@@ -901,7 +907,7 @@
|
||||
}
|
||||
#getAllKnownDirectoryPathsLowerCase() {
|
||||
if (this.#knownDirectoryPathsLowerCase != null) return this.#knownDirectoryPathsLowerCase;
|
||||
- this.#knownDirectoryPathsLowerCase = this.#getAllKnownDirectoryPaths().map(toLowerCaseSearchPath);
|
||||
+ this.#knownDirectoryPathsLowerCase = this.#getAllKnownDirectoryPaths().map(path => this.#formatSearchPath(path));
|
||||
return this.#knownDirectoryPathsLowerCase;
|
||||
}
|
||||
#invalidateKnownPathCaches() {
|
||||
@@ -1025,7 +1031,7 @@
|
||||
const matchingPathSet = /* @__PURE__ */ new Set();
|
||||
let focusCandidate = null;
|
||||
for (let index = 0; index < listedPaths.length; index += 1) {
|
||||
- if (!listedPathsLowerCase[index].includes(searchValue)) continue;
|
||||
+ if (!listedPathsLowerCase[index].includes(searchValue) && !toLowerCaseSearchPath(listedPaths[index]).includes(searchValue)) continue;
|
||||
const path = listedPaths[index];
|
||||
matchingPaths.push(path);
|
||||
matchingPathSet.add(path);
|
||||
@@ -1034,7 +1040,7 @@
|
||||
const knownDirectoryPaths = this.#getAllKnownDirectoryPaths();
|
||||
const knownDirectoryPathsLowerCase = this.#getAllKnownDirectoryPathsLowerCase();
|
||||
for (let index = 0; index < knownDirectoryPaths.length; index += 1) {
|
||||
- if (!knownDirectoryPathsLowerCase[index].includes(searchValue)) continue;
|
||||
+ if (!knownDirectoryPathsLowerCase[index].includes(searchValue) && !toLowerCaseSearchPath(knownDirectoryPaths[index]).includes(searchValue)) continue;
|
||||
const path = knownDirectoryPaths[index];
|
||||
if (matchingPathSet.has(path)) continue;
|
||||
matchingPaths.push(path);
|
||||
diff --git a/node_modules/@pierre/trees/dist/render/FileTree.js b/node_modules/@pierre/trees/dist/render/FileTree.js
|
||||
--- a/node_modules/@pierre/trees/dist/render/FileTree.js
|
||||
+++ b/node_modules/@pierre/trees/dist/render/FileTree.js
|
||||
@@ -1,3 +1,4 @@
|
||||
+/*! Modified by Qwen Code: display-only formatItemName and search; see package NOTICE. */
|
||||
import { getBuiltInSpriteSheet, isColoredBuiltInIconSet } from "../builtInIcons.js";
|
||||
import { FILE_TREE_STYLE_ATTRIBUTE, FILE_TREE_UNSAFE_CSS_ATTRIBUTE, HEADER_SLOT_NAME } from "../constants.js";
|
||||
import { normalizeFileTreeIcons } from "../iconConfig.js";
|
||||
@@ -64,6 +65,7 @@
|
||||
#id;
|
||||
#onSelectionChange;
|
||||
#renderRowDecoration;
|
||||
+ #formatItemName;
|
||||
#renamingEnabled;
|
||||
#searchBlurBehavior;
|
||||
#searchEnabled;
|
||||
@@ -83,7 +85,7 @@
|
||||
#wroteHostItemHeight = false;
|
||||
#wroteHostDensityFactor = false;
|
||||
constructor(options) {
|
||||
- const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount, ...controllerOptions } = options;
|
||||
+ const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange, onSelectionChange, overscan, formatItemName, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount, ...controllerOptions } = options;
|
||||
this.#composition = composition;
|
||||
this.#id = createClientId(id);
|
||||
this.#gitStatusState = resolveFileTreeGitStatusState(gitStatus);
|
||||
@@ -91,6 +93,7 @@
|
||||
this.#unsafeCSS = unsafeCSS;
|
||||
this.#onSelectionChange = onSelectionChange;
|
||||
this.#renderRowDecoration = renderRowDecoration;
|
||||
+ this.#formatItemName = formatItemName;
|
||||
this.#renamingEnabled = renaming != null && renaming !== false;
|
||||
this.#searchBlurBehavior = searchBlurBehavior;
|
||||
this.#searchEnabled = search === true;
|
||||
@@ -104,6 +107,7 @@
|
||||
};
|
||||
this.#controller = new FileTreeController({
|
||||
...controllerOptions,
|
||||
+ formatItemName,
|
||||
fileTreeSearchMode,
|
||||
initialSearchQuery,
|
||||
onSearchChange,
|
||||
@@ -310,6 +314,7 @@
|
||||
icons: this.#icons,
|
||||
instanceId: this.#id,
|
||||
renamingEnabled: this.#renamingEnabled,
|
||||
+ formatItemName: this.#formatItemName,
|
||||
renderRowDecoration: this.#renderRowDecoration,
|
||||
searchBlurBehavior: this.#searchBlurBehavior,
|
||||
searchEnabled: this.#searchEnabled,
|
||||
@@ -447,12 +452,13 @@
|
||||
}
|
||||
};
|
||||
function preloadFileTree(options) {
|
||||
- const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange: _onSearchChange, onSelectionChange: _onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount, ...controllerOptions } = options;
|
||||
+ const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange: _onSearchChange, onSelectionChange: _onSelectionChange, overscan, formatItemName, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount, ...controllerOptions } = options;
|
||||
const resolvedDensity = resolveFileTreeDensity(density, itemHeight);
|
||||
const resolvedItemHeight = resolvedDensity.itemHeight;
|
||||
const resolvedId = createServerId(id);
|
||||
const controller = new FileTreeController({
|
||||
...controllerOptions,
|
||||
+ formatItemName,
|
||||
fileTreeSearchMode,
|
||||
initialSearchQuery,
|
||||
renaming
|
||||
@@ -478,6 +484,7 @@
|
||||
itemHeight: resolvedItemHeight,
|
||||
overscan,
|
||||
renamingEnabled: renaming != null && renaming !== false,
|
||||
+ formatItemName,
|
||||
renderRowDecoration,
|
||||
searchBlurBehavior,
|
||||
searchEnabled: search === true,
|
||||
diff --git a/node_modules/@pierre/trees/dist/render/FileTreeView.js b/node_modules/@pierre/trees/dist/render/FileTreeView.js
|
||||
--- a/node_modules/@pierre/trees/dist/render/FileTreeView.js
|
||||
+++ b/node_modules/@pierre/trees/dist/render/FileTreeView.js
|
||||
@@ -1,3 +1,4 @@
|
||||
+/*! Modified by Qwen Code: display-only formatItemName and search; see package NOTICE. */
|
||||
import { CONTEXT_MENU_SLOT_NAME, CONTEXT_MENU_TRIGGER_TYPE, HEADER_SLOT_NAME } from "../constants.js";
|
||||
import { createFileTreeIconResolver } from "./iconResolver.js";
|
||||
import { FILE_TREE_DEFAULT_ITEM_HEIGHT } from "../model/virtualization.js";
|
||||
@@ -401,6 +402,11 @@
|
||||
] });
|
||||
}
|
||||
function renderStyledRow(frame, row, key, options = {}) {
|
||||
+ if (frame.formatItemName) row = {
|
||||
+ ...row,
|
||||
+ name: frame.formatItemName(row.name),
|
||||
+ flattenedSegments: row.flattenedSegments?.map(segment => ({ ...segment, name: frame.formatItemName(segment.name) }))
|
||||
+ };
|
||||
const { controller, renameView, visualFocusPath, contextHoverPath, draggedPathSet, dragTarget, dragAndDropEnabled, shouldSuppressContextMenu, handleRowDragStart, handleRowDragEnd, handleRowTouchStart, instanceId, itemHeight, gitStatusByPath, ignoredGitDirectories, ignoredInheritanceCache, directoriesWithGitChanges, gitLaneActive, contextMenuEnabled, contextMenuTriggerMode, contextMenuButtonTriggerEnabled, contextMenuButtonVisibility, contextMenuRightClickEnabled, registerRenameInput, registerButton, resolveIcon, renderDecorationForRow, openContextMenuForRow, onRowClick, onKeyDown } = frame;
|
||||
const targetPath = getFileTreeRowPath(row);
|
||||
const { isParked = false, mode = "flow", style } = options;
|
||||
@@ -517,7 +523,7 @@
|
||||
if (range.end < range.start) return [];
|
||||
return frame.controller.getVisibleRows(range.start, range.end).filter((row) => !hiddenRowPaths.has(getFileTreeRowPath(row))).map((row, slotIndex) => renderStyledRow(frame, row, range.start + slotIndex));
|
||||
}
|
||||
-function FileTreeView({ composition, controller, gitStatusByPath, ignoredGitDirectories, directoriesWithGitChanges, icons, instanceId, itemHeight = FILE_TREE_DEFAULT_ITEM_HEIGHT, overscan = 10, renamingEnabled = false, renderRowDecoration, searchBlurBehavior = "close", searchEnabled = false, searchFakeFocus = false, slotHost, stickyFolders = false, initialViewportHeight = 420 }) {
|
||||
+function FileTreeView({ composition, controller, gitStatusByPath, ignoredGitDirectories, directoriesWithGitChanges, icons, instanceId, itemHeight = FILE_TREE_DEFAULT_ITEM_HEIGHT, overscan = 10, renamingEnabled = false, formatItemName, renderRowDecoration, searchBlurBehavior = "close", searchEnabled = false, searchFakeFocus = false, slotHost, stickyFolders = false, initialViewportHeight = 420 }) {
|
||||
"use no memo";
|
||||
const contextMenuAnchorRef = useRef(null);
|
||||
const contextMenuTriggerRef = useRef(null);
|
||||
@@ -1759,6 +1765,7 @@
|
||||
registerRenameInput,
|
||||
renameView,
|
||||
renderDecorationForRow,
|
||||
+ formatItemName,
|
||||
resolveIcon,
|
||||
shouldSuppressContextMenu,
|
||||
visualFocusPath
|
||||
diff --git a/node_modules/@pierre/trees/dist/render/FileTreeView.d.ts b/node_modules/@pierre/trees/dist/render/FileTreeView.d.ts
|
||||
--- a/node_modules/@pierre/trees/dist/render/FileTreeView.d.ts
|
||||
+++ b/node_modules/@pierre/trees/dist/render/FileTreeView.d.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
+/*! Modified by Qwen Code: display-only formatItemName and search; see package NOTICE. */
|
||||
import { FileTreeViewProps } from "../model/internalTypes.js";
|
||||
import { JSX } from "preact";
|
||||
|
||||
@@ -13,6 +14,7 @@
|
||||
itemHeight,
|
||||
overscan,
|
||||
renamingEnabled,
|
||||
+ formatItemName,
|
||||
renderRowDecoration,
|
||||
searchBlurBehavior,
|
||||
searchEnabled,
|
||||
Generated
+6
-3
@@ -1539,6 +1539,9 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.26
|
||||
version: 3.14.2([email protected]([email protected]))([email protected])
|
||||
'@types/hast':
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
@@ -1627,9 +1630,6 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2([email protected](@types/[email protected])([email protected]))
|
||||
'@types/hast':
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.20.1
|
||||
@@ -1669,6 +1669,9 @@ importers:
|
||||
shadcn:
|
||||
specifier: ^4.13.0
|
||||
version: 4.13.0([email protected])([email protected])
|
||||
shiki-files:
|
||||
specifier: npm:[email protected]
|
||||
version: [email protected]
|
||||
tailwindcss:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2
|
||||
|
||||
@@ -64,7 +64,12 @@ function write(fixture, name, contents) {
|
||||
// the build emits below `dist/` is on disk yet absent from the tarball.
|
||||
const files = ['dist/*.js', 'dist/types'];
|
||||
|
||||
function declarePackage(fixture, exports, packageFiles = files) {
|
||||
function declarePackage(
|
||||
fixture,
|
||||
exports,
|
||||
packageFiles = files,
|
||||
dependencies = {},
|
||||
) {
|
||||
write(fixture, 'NOTICES.txt', 'Bundled dependency licenses');
|
||||
write(fixture, 'package.json', {
|
||||
name: 'fixture-web-shell',
|
||||
@@ -72,10 +77,49 @@ function declarePackage(fixture, exports, packageFiles = files) {
|
||||
type: 'module',
|
||||
files: ['NOTICES.txt', ...packageFiles],
|
||||
exports,
|
||||
dependencies,
|
||||
});
|
||||
}
|
||||
|
||||
describe('web-shell publish artifact verifier', () => {
|
||||
it.each(['shiki-files/core', '@shikijs/types'])(
|
||||
'refuses declarations depending on undeclared %s',
|
||||
(specifier) => {
|
||||
const result = runVerifier((fixture) => {
|
||||
declarePackage(fixture, { '.': './dist/index.js' });
|
||||
write(fixture, 'dist/index.js', 'export {};');
|
||||
write(
|
||||
fixture,
|
||||
'dist/types/private.d.ts',
|
||||
`export type X = typeof import('${specifier}');`,
|
||||
);
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
`private.d.ts imports the repo-only ${specifier}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts self, relative, builtin and declared type dependencies', () => {
|
||||
const result = runVerifier((fixture) => {
|
||||
declarePackage(fixture, { '.': './dist/index.js' }, files, {
|
||||
shiki: '*',
|
||||
'@types/hast': '*',
|
||||
});
|
||||
write(fixture, 'dist/index.js', 'export {};');
|
||||
write(
|
||||
fixture,
|
||||
'dist/types/index.d.ts',
|
||||
`export type A = typeof import('fixture-web-shell/daemon-react-sdk');
|
||||
export type B = typeof import('./relative');
|
||||
export type C = typeof import('node:fs');
|
||||
export type D = typeof import('shiki/core');
|
||||
export type E = import('hast').Root;`,
|
||||
);
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
it('refuses bundled dependency notices excluded from the npm package', () => {
|
||||
const result = runVerifier((fixture) => {
|
||||
write(fixture, 'NOTICES.txt', 'Bundled dependency licenses');
|
||||
|
||||
Reference in new issue
Block a user