diff --git a/AGENTS.md b/AGENTS.md index 1a1fabe..b9ba6e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,9 @@ interface TabDescriptor { /** * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, - * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于 + * `settings.texts` 追加多行文本输入行(每行一个值,失焦提交到 + * SidebarPrefs 的字符串数组字段),均绑定 SidebarPrefs 字段。嵌套设置仅 + * 父级启用时显示(齿轮按钮在父级卡片上)。v0.11.0 起行控件不限于 * 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number * 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。 * v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置): @@ -171,6 +173,13 @@ interface TabDescriptor { /** 输入框后的单位后缀(如 'px')。 */ unit?: string }[] + texts?: readonly { + /** SidebarPrefs 字符串数组字段名(内置键:'explorerExclude') */ + key: string + title: string | (() => string) + desc?: string | (() => string) + placeholder?: string | (() => string) + }[] /** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的, * 持久化在 `pluginSettings[]`——不需要宿主 PrefsSchema 字段。 */ pluginToggles?: readonly { @@ -328,7 +337,7 @@ interface FileViewerDescriptor { * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */ load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */ - settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] } + settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[]; texts?: readonly { key: string; title: string | (() => string); desc?: string | (() => string); placeholder?: string | (() => string) }[] } /** 渲染函数 */ component: (props: FileViewerProps) => ReactNode } @@ -503,7 +512,7 @@ interface OpenTabSeed { } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+);`settings.texts` 声明的文本设置(如资源管理器的 `explorerExclude`)在同一弹窗中渲染**多行文本输入**(每行一个值,失焦提交到 SidebarPrefs 的字符串数组字段)——父级卡片关闭时齿轮隐藏;`settings.toggles` / `settings.texts` 的 **key 必须是宿主 PrefsSchema 的字段**(内置 toggles 键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`;内置 texts 键:`explorerExclude`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 --- diff --git a/README.md b/README.md index f76a986..616af68 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 +- **🗂️ 文件工作台**:资源管理器(懒加载目录树,支持按模式排除文件/目录——设置页「排除模式」可配,如 `*.meta`、`node_modules`,单个 `*` 通配、大小写不敏感)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe,外链默认在侧边栏打开 - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 @@ -251,6 +251,7 @@ pnpm watch # tsdown --watch ## ⚠️ 已知限制 - Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截 +- 资源管理器排除是显示层过滤:超大目录(单层 >1000 项)的截断计数在过滤后可能偏少 - 终端 Tab 拖到另一分栏会重挂载(shell 重开) - Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底 - 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈 diff --git a/README_EN.md b/README_EN.md index 6512dde..d244dd8 100644 --- a/README_EN.md +++ b/README_EN.md @@ -19,7 +19,7 @@ ## ✨ Features -- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office +- **🗂️ File Workbench**: file explorer (lazy-loading tree, with pattern-based file/directory exclusion — the "Exclude patterns" setting, e.g. `*.meta`, `node_modules`; single `*` wildcard, case-insensitive) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office - **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links open in the sidebar by default - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert @@ -251,6 +251,7 @@ pnpm watch # tsdown --watch ## ⚠️ Known Limitations - Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted +- Explorer excludes are a display-layer filter: the truncation counter of very large directories (>1000 entries per level) may undercount after filtering - Dragging a terminal tab to another pane remounts it (shell restarts) - Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks - Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack diff --git a/docs/plans/2026-08-14-explorer-exclude-design.md b/docs/plans/2026-08-14-explorer-exclude-design.md new file mode 100644 index 0000000..30dd033 --- /dev/null +++ b/docs/plans/2026-08-14-explorer-exclude-design.md @@ -0,0 +1,122 @@ +# 资源管理器:按模式排除文件/目录(issue #18) + +**日期**:2026-08-14 +**状态**:已实施(PR 待合) +**目标版本**:v0.11.0(待定) + +## 1. 目标 + +让资源管理器可以像常见 IDE 一样按规则**隐藏**不需要的文件/目录(issue #18 的 Unity 场景:过滤 `.meta` 这类系统生成文件)。用户通过设置页为 explorer tab 维护一组「排除模式」,匹配的条目在树中不显示。 + +## 2. 非目标 + +- **不做 glob 全量支持**(`**`、`?`、`{a,b}`、`[x]` 等):无依赖优先,单 `*` 通配足够覆盖 `.meta` / `node_modules` / `build` 场景;需要更强模式时后续再加。 +- **不默认过滤任何东西**:`explorerExclude` 默认 `[]`,行为与现状完全一致(符合项目「缺席 = 默认启用」惯例)。 +- **不碰 host 半**:过滤是纯显示层偏好,host 的 `fs.tree` 路由、`listDirectory`、其他消费者(git/editor 等)全部不动。目录超大(>1000 项)时 `truncated` 计数可能把本可显示的行挤掉——可接受,文档已知限制记录。 +- **不做 .gitignore 解析 / 内置噪声规则预设**:保持可预期,规则完全由用户显式声明。 + +## 3. 现状回顾 + +- `ExplorerView`(`src/client/ExplorerView.tsx`)懒加载目录树:`api.fsTree` → `fs.tree` 路由 → host `listDirectory` 返回 `SidebarFsEntry[]`(`name/path/isDir/hidden`),client 渲染时目录优先排序,dotfile 变暗显示。 +- 设置体系:`SidebarPrefs`(`prefs-shared.ts` 共享类型 + `config.ts` schemastery schema + `client/prefs.ts` parsePrefs 校验 + `SidebarStore` 持有 + `/sidebar/settings` RPC 持久化)。设置页 `SideCardSection` 按注册表渲染 tab/viewer 卡片,卡片齿轮弹窗渲染 `settings.toggles`(目前仅布尔复选框行)。 +- explorer tab 在 `builtins/tabs.tsx` 注册,`component` 收到 `{ ctx, store, scope, expanded, onToggleDir, onReferenceFile }`,`store` 是 `SidebarStore`(`getPrefs()` / `subscribe()` 可用)。 + +## 4. 设计 + +### 4.1 模式匹配(新 `src/exclude-patterns.ts`,纯函数、零依赖) + +```ts +/** 单模式匹配:单个 '*' 通配 0+ 字符;无 '*' = 精确名称匹配;大小写不敏感。 */ +export function matchesExcludePattern(name: string, pattern: string): boolean + +/** 任一模式命中(空模式/空白串忽略)。 */ +export function isExcludedName(name: string, patterns: readonly string[]): boolean +``` + +- 匹配对象是**条目名**(`entry.name`),不是完整路径——目录和文件一视同仁(`node_modules`、`build`、`*.meta` 都自然成立),且不需要从 client 向 host 传 cwd 相关逻辑。 +- 大小写不敏感:跨平台一致(Windows/macOS 文件系统本身不敏感),用户心智中 `.meta` 与 `.META` 是同一文件。 +- 实现:无 `*` 时直接 `toLowerCase()` 相等;有 `*` 时把模式转正则(其余字符 `RegExp.escape` 语义手写转义),`*` → `.*`,锚定 `^...$`。支持 `*.meta`、`build*`、`*.min.js`、`foo*bar`。 +- 放 `src/` 根级共享(host/client 都能 import,当前只用 client;对齐 `prefs-shared.ts` 位置)。 + +### 4.2 偏好字段(三处同步) + +`SidebarPrefs` 新增: + +```ts +/** 资源管理器排除模式列表:匹配(条目名,单 '*' 通配,大小写不敏感)的条目在树中隐藏。 */ +explorerExclude: string[] +``` + +- `prefs-shared.ts`:接口 + `SIDEBAR_PREFS_DEFAULTS.explorerExclude = []` +- `config.ts` `PrefsSchema`:`explorerExclude: z.array(z.string()).default([])` +- `client/prefs.ts` `parsePrefs`:新增 `stringArrayOf(record.explorerExclude)`——非数组回退 `[]`;数组过滤出非空 string、trim、去重、保序(对齐 `booleanMapOf` 的防御风格)。 + +### 4.3 设置 UI:声明式「文本行」 + +现有 `settings.toggles` 只支持布尔。扩展 `SidebarSettingsDeclaration` 增加**文本行**(多行文本框,每行一个模式): + +```ts +/** 声明式文本设置:多行文本框(每行一个值),失焦/Enter 提交到 SidebarPrefs 数组字段。 */ +export interface SidebarSettingText { + key: string // SidebarPrefs 数组字段名('explorerExclude') + title: string | (() => string) + desc?: string | (() => string) + placeholder?: string | (() => string) +} + +export interface SidebarSettingsDeclaration { + toggles?: readonly SidebarSettingToggle[] + texts?: readonly SidebarSettingText[] // 新增 +} +``` + +- 弹窗 `FeatureSettingsRows` 支持两种行:复选框行(现逻辑不变)+ 文本行(新组件 `TextSettingRow`:textarea + title/desc/placeholder)。 +- 文本行交互:focus 时从 `prefs[key]` 初始化 draft;编辑为本地 state;blur(或 Enter,非 Shift)时 trim 行 → 去空 → 去重 → `commit({ [key]: lines })`;提交成功/失败后 `applyOutcome` 更新 prefs,draft 随 prefs 同步(`useEffect` 监听外部变化,避免并发写后残留)。 +- 弹窗宽度/样式沿用现有 `popupRows`;textarea 样式新增(多行、等宽字体、行间紧凑)。 + +### 4.4 explorer tab 声明 + +`builtins/tabs.tsx` explorer 描述符加: + +```ts +settings: { + texts: [{ + key: 'explorerExclude', + title: () => t('settingsExplorerExcludeTitle'), + desc: () => t('settingsExplorerExcludeDesc'), + placeholder: () => t('settingsExplorerExcludePlaceholder'), + }], +} +``` + +卡片齿轮按钮的显隐条件 `(tab.settings?.toggles?.length ?? 0) > 0` 扩展为 `> 0 || texts > 0`。 + +### 4.5 ExplorerView 过滤 + +- `ExplorerView` 新增 prop `exclude: string[]`,由 `builtins/tabs.tsx` 传入 `store.getPrefs().explorerExclude`。 +- `ExplorerView` 内部订阅 prefs 变更(新 prop `store` 传入,`useEffect(() => store.subscribe(...))` + useState 快照),排除规则变化即时生效。 +- `renderLevel` 渲染前 `entries.filter(e => !isExcludedName(e.name, exclude))`。被过滤目录不渲染;`expanded` 中残留被过滤目录的路径时照常 loadDir(数据无害),仅不渲染。 +- 现有能力不变:被过滤文件不显示,因此右键复制路径 / 打开 / @引用对它们不可达(符合「隐藏」语义)。 + +### 4.6 i18n + +`locales.ts` zh/en 各加 3 条: + +| key | zh | en | +|---|---|---| +| settingsExplorerExcludeTitle | 排除模式 | Exclude patterns | +| settingsExplorerExcludeDesc | 匹配(名称,* 通配,大小写不敏感)的条目在树中隐藏,每行一个 | Entries whose name matches (single `*` wildcard, case-insensitive) are hidden from the tree; one per line | +| settingsExplorerExcludePlaceholder | 例如:\*.meta、.DS_Store、node_modules | e.g. \*.meta, .DS_Store, node_modules | + +## 5. 测试计划 + +1. `tests/exclude-patterns.spec.ts`:精确名、`*` 前缀/后缀/中间、大小写、空模式/空白串、正则特殊字符转义(`foo[bar]`、`a+b`)。 +2. `tests/unit.spec.ts` 追加 `parsePrefs` 的 `explorerExclude` 用例:缺省/非法值回退 `[]`、过滤非 string/空串、trim、去重保序。 +3. `tests/side-card-section.spec.tsx` 追加:声明 `texts` 的 tab 卡片有齿轮;`FeatureSettingsRows` 渲染文本行(title/desc/placeholder 出现、textarea 值 = 模式 join('\n'));`onCommitText` 提交解析后的行。 +4. `tests/builtins.spec.ts` 追加:explorer 描述符声明 `settings.texts` 且 key 为 `explorerExclude`。 +5. ExplorerView 过滤渲染测试(`tests/` 新增或并入现有):mock `api.fsTree` 返回含 `.meta` 的 entries,`exclude: ['*.meta']` 时渲染不含该行;空规则时全部渲染。 + +## 6. 文档 + +- `README.md` 功能一览「资源管理器」补一句:支持按模式隐藏文件/目录(设置页可配,如 `*.meta`);「已知限制」补:过滤是显示层,超大目录(>1000 项)的截断计数在过滤后可能偏少。 +- `README_EN.md` 同步。 diff --git a/package.json b/package.json index a8c8b31..cfc6000 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,8 @@ "react-dom": "18.2.0", "tsdown": "^0.22.2", "typescript": "^5.6.0", + "unrun": "^0.3.1", "vitest": "^4.1.8" - } + }, + "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf62710..257b676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,10 +176,13 @@ importers: version: 18.2.0(react@18.2.0) tsdown: specifier: ^0.22.2 - version: 0.22.14(typescript@5.6.2) + version: 0.22.14(typescript@5.6.2)(unrun@0.3.1) typescript: specifier: ^5.6.0 version: 5.6.2 + unrun: + specifier: ^0.3.1 + version: 0.3.1 vitest: specifier: ^4.1.8 version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)) @@ -2024,6 +2027,36 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unrun@0.3.1: + resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} + engines: {node: ^22.13.0 || >=24.0.0} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -4235,7 +4268,7 @@ snapshots: trim-lines@3.0.1: {} - tsdown@0.22.14(typescript@5.6.2): + tsdown@0.22.14(typescript@5.6.2)(unrun@0.3.1): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -4254,6 +4287,7 @@ snapshots: verkit: 0.3.2 optionalDependencies: typescript: 5.6.2 + unrun: 0.3.1 transitivePeerDependencies: - '@typescript/native-preview' - '@volar/typescript' @@ -4301,6 +4335,25 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unrun@0.3.1: + dependencies: + rolldown: 1.2.4 + + use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.2.0): + dependencies: + react: 18.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.31 + + use-sidecar@1.1.3(@types/react@18.3.31)(react@18.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 18.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.31 + use-sync-external-store@1.2.0(react@18.2.0): dependencies: react: 18.2.0 diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx index d0eb593..e04e794 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/ExplorerView.tsx @@ -10,15 +10,17 @@ * "copied" label replacing the button after a successful write); file rows * also offer a download action (the host serves raw bytes, binary-safe). */ -import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { api, downloadUrl, type FsEntry } from './api.ts' +import { compileExcludePatterns } from '../exclude-patterns.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' +import type { SidebarStore } from './state.ts' import css from './sidebar.module.css' interface LevelData { @@ -39,16 +41,28 @@ const COPIED_MS = 1200 export function ExplorerView(props: { sessionId: string cwd: string | undefined + /** The shared store: the explorer excludes read from `store.getPrefs()` + * and re-render live when the settings change (issue #18). */ + store: SidebarStore expanded: string[] onToggle: (path: string) => void onOpenFile: (path: string) => void /** Insert `@` into the composer draft. */ onReferenceFile: (path: string) => void }) { - const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props + const { sessionId, cwd, store, expanded, onToggle, onOpenFile, onReferenceFile } = props const [data, setData] = useState>({}) const dataRef = useRef(data) const [refreshTick, setRefreshTick] = useState(0) + // Live exclude patterns: follow the shared prefs so a settings-page edit + // hides/show rows without reopening or manual refresh. + const [exclude, setExclude] = useState(() => store.getPrefs().explorerExclude) + useEffect(() => store.subscribe(() => { + setExclude(store.getPrefs().explorerExclude) + }), [store]) + // Precompiled matcher: the regexes are built once per pattern list, not + // per row per render (large levels × many patterns stay cheap). + const excluded = useMemo(() => compileExcludePatterns(exclude), [exclude]) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ @@ -142,7 +156,9 @@ export function ExplorerView(props: { ) } const entries = level.entries ?? [] - return entries.map(entry => { + // Hide entries whose NAME matches an exclude pattern (directories and + // files alike, case-insensitive single-`*` wildcards — issue #18). + return entries.filter(entry => !excluded(entry.name)).map(entry => { if (entry.isDir) { const isOpen = expanded.includes(entry.path) return ( diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 18f9823..31d04b7 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -358,6 +358,40 @@ outline-offset: 2px; } +/* One declarative TEXT row: title/desc stacked above a multi-line input + (vertical layout, unlike the horizontal checkbox row). */ +.textRow { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +/* The multi-line pattern input: monospace, compact, DSH text tokens. */ +.textSetting { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 12px; + line-height: 18px; + resize: vertical; +} + +.textSetting:focus-visible { + outline: 2px solid var(--dsw-alias-border-l4); + outline-offset: 1px; +} + +.textSetting::placeholder { + color: var(--dsw-alias-label-tertiary); +} + .control { flex: none; display: flex; diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index c0e4950..49d5f02 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -23,11 +23,12 @@ * A card's on/off state is its VISUAL STATE: enabled = highlighted (brand * border + tinted fill + a circular check badge pinned to the card's far * right), disabled = neutral and dimmed. Features that declare - * `settings.toggles` carry a gear corner button that opens a native Modal - * (wider than the primitive default) with the related settings as - * title/desc + custom-switch rows and a Done footer. The toggles - * themselves are custom switches: a real checkbox (native semantics and - * focus) driving a styled track/thumb. + * `settings.toggles` / `settings.texts` carry a gear corner button that + * opens a native Modal (wider than the primitive default) with the related + * settings as title/desc + custom-switch rows, free-form / number input + * rows, and/or multi-line text rows (e.g. the Explorer page's exclude + * patterns) and a Done footer. The toggles themselves are custom switches: + * a real checkbox (native semantics and focus) driving a styled track/thumb. * * Writes ride the plugin's own fenced settings route (the host calls the * settings seam in-process — the DSH settings RPC domain does not serve @@ -38,7 +39,7 @@ * shows the wire error inline — a broken settings surface never crashes the * shell. */ -import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { IconCheckOutline16, IconPlusOutline16, @@ -65,6 +66,7 @@ import type { BetterSidebarService, FileViewerDescriptor, SidebarSettingsRenderProps, + SidebarSettingText, SidebarSettingToggle, TabDescriptor, } from './service.ts' @@ -115,6 +117,7 @@ function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean { const settings = feature.settings return settings !== undefined && ( (settings.toggles?.length ?? 0) > 0 + || (settings.texts?.length ?? 0) > 0 || (settings.pluginToggles?.length ?? 0) > 0 || settings.render !== undefined ) @@ -125,6 +128,75 @@ function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string { return textOf('title' in feature ? feature.title : undefined) || feature.id } +/** Read one string-array pref by declarative key (missing/non-array = []). */ +function prefStrings(prefs: SidebarPrefs, key: string): string[] { + const value = (prefs as unknown as Record)[key] + return Array.isArray(value) ? value : [] +} + +/** + * One declarative TEXT row: a multi-line input (one value per line) that + * commits on blur — the explorer exclude patterns. Draft stays local while + * typing; the external value (prefs round-trip) re-syncs the draft only + * when the committed array actually changes, so in-progress edits are + * never clobbered by re-renders. + */ +export function TextSettingRow(props: { + text: SidebarSettingText + prefs: SidebarPrefs + onCommit: (text: SidebarSettingText, lines: string[]) => void +}) { + const { text, prefs, onCommit } = props + const title = textOf(text.title) + const value = prefStrings(prefs, text.key) + const serialized = useMemo(() => value.join('\n'), [value]) + const [draft, setDraft] = useState(serialized) + useEffect(() => { setDraft(serialized) }, [serialized]) + /** Normalize: trim, drop blanks, dedupe preserving order. */ + const normalized = (): string[] => { + const lines: string[] = [] + for (const line of draft.split('\n')) { + const trimmed = line.trim() + if (trimmed === '' || lines.includes(trimmed)) continue + lines.push(trimmed) + } + return lines + } + /** Commit only when the edit actually changed the patterns (blur on a + * pristine box writes nothing). */ + const commit = (): void => { + const lines = normalized() + if (lines.length === value.length && lines.every((line, i) => line === value[i])) return + onCommit(text, lines) + } + return ( +
+ + {title} + {textOf(text.desc) !== '' && {textOf(text.desc)}} + +