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:
authored and GitHub committed 2026-10-16 16:45:56 +08:00
1 parent 38d58c63cf
commit a8eb74fdf5
33 files changed
+1379 -76

No files matched your search

+3
View File
@@ -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
+4 -3
View File
@@ -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
+3 -2
View File
@@ -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 格式沿用现有的仅下载行为。
+66
View File
@@ -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
View File
@@ -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)
+32
View File
@@ -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.
+6
View File
@@ -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');
});
+6 -3
View File
@@ -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",
+81
View File
@@ -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}`);
}
}
+1
View File
@@ -2,6 +2,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"stripInternal": true,
"emitDeclarationOnly": true,
"declarationDir": "dist/types",
"noEmit": false
+12 -1
View File
@@ -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(),
{
+3
View File
@@ -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',
+3
View File
@@ -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.
+242
View File
@@ -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,
+6 -3
View File
@@ -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');