# dsh-project-panel 开发笔记 面向 DSH web 端(`0.2.0-rc.2`)的工程面板插件。当前形态:**右侧栏的一个 tab** + 侧栏底部一个「工程」按钮。中间栏(会话 + 输入框)保持原样,绝不占用。 ## 目录 ``` dsh-project-panel/ package.json 依赖只有给脚手架用的那 4 个(见下);dsh.bundle.patch / dsh.client.platform = "web" cordis.patch.yml insert: dsh-project-panel lib/index.js host 半边:desktopProject/* RPC lib/client.js client 半边:手写 ESM,改了刷新即生效 lib/client.editor.js ⚠ 构建产物(644 KB,CodeMirror 6)—— 不要手改 src/editor.js 编辑器分片的源码,npm run build:editor 生成上面那个 scaffold/ 自带的平台脚本模板(source-*.js + utils.js + package.json) NOTES.md 这份笔记 ``` ### 仓库里**没有**的东西 开发时用过、但不进仓库的两个目录(`.gitignore` 里挡着,本地重新建也不会被提交): - `_dbg/lintprobe/` —— 24 个 `.mjs` 探针,是改这个插件时的**唯一回归网**。套路是 「复制一份 `lib/index.js`,把 `const HANDLERS = {` 换成 `export`,再 import 真模块」; `scriptsui.mjs` / `runcallbacks.mjs` / `treemenu.mjs` / `pushbadge.mjs` 那类则是从 `lib/client.js` 里**原文抠出** `renderScripts` / 回调 / `renderPushBadge`,喂一个假的 `h` 再断言。**笔记里凡是写 `_dbg/lintprobe/xxx.mjs` 的地方,路径都已经不存在** —— 留下的只是「当时怎么验的」和结论,要重跑得照描述重建那个探针。 - `_ref/` —— 两个第三方仓库的克隆,只用来对照原版实现: `gitee-dsh` ← `https://gitee.com/nblt_1/dsh.git`(`176afdc`)、 `magical-src` ← `https://github.com/lzqzm/dsh-magical-lowcode-project.git`(`b5f5f20`)。 笔记里 `_ref/gitee-dsh/plugins/dsh-desktop-project/lib/index.js:532-584` 指的是前者里 那三个**还没移植**的 RPC:`projectSetProjectName` / `projectAssemblePreview` / `projectRenameEntry` —— 要接着做就按上面的地址重新 clone 一份。 (`_dbg/scriptbak/` 是平台脚本改造前的备份,连同 `_dbg/` 一起移除了;脚本本体在 `scaffold/` 里。) 装进 profile: ``` dsh plugin --profile web add <本仓库的绝对路径> ``` `link:` 安装不装传递依赖,所以**两个半边都不许 import 第三方包,也不许 import `@deepseek-ai/*`**(Node 从插件真实路径向上解析,够不到 `profile/node_modules`)。顶层 import 失败会打挂整个 profile。 `node_modules/` 里分两类,**运行时都不参与 host / client 的 import**: - 构建期:esbuild + CodeMirror(编辑器代码已经打进 `lib/client.editor.js`)。 - 给**脚手架**用的:axios / archiver / form-data / unzipper。铺到新工作区的 `source-*.js` 要 require 它们,但那边的目录里没有 `node_modules` —— 所以由 `projectRunScript` 给子进程塞 `NODE_PATH=<插件根>/node_modules`(详见 「空目录也能拉项目」一节)。`package.json` 里那个 `"//dependencies"` 注释键 就是给未来的自己留的说明:这几个不属于本插件的运行依赖。 改了东西之后要做什么: | 改了哪 | 要做什么 | | --- | --- | | `lib/client.js` | 刷新页面 | | `lib/index.js`(host) | 重启 `dsh web` | | `src/editor.js` | `npm run build:editor`,再刷新页面 | ## client 半边的硬约束 - 产物必须是 `window.__ModuleLoader__.load({ id: "", factory: (require) => exports })`。 `id` 必须逐字等于 npm 包名。 - **只能 require 这 9 个说明符**:`react`、`react/jsx-runtime`、`react-dom`、 `react-dom/client`、`@deepseek-ai/cordis`、`@deepseek-ai/dsh-client-store`、 `@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-primitives`、 `@deepseek-ai/dsh-client-ui-dockkit`。其他说明符运行时直接抛错。 - 具名导出 `apply` 与 `inject`,**没有 default export**。`inject` 是 cordis 服务键数组。 - 所有注册都走 `slots.inject(key, () => slots.register(...))`,让 disposer 落在本插件 fiber 上。 - **不能在模块体做副作用**:整个文件只定义函数并调用一次 `load()`。 - **绝不注册 `root` scope 的 slot**(`sidebar` / `main` / `rightbar` / `shell.overlay` / `shell.leading`):single 且被 shell 独占,注册会 shadow 整个 AppFrame。 ## 右侧栏 tab 的注册(两段式) **第一段**——`ctx.sidebarRightTabs.register(definition)`,声明这个类型是 什么。契约见 `@deepseek-ai/dsh-client-ui-sidebar-right/lib/types/client/tab-registry.d.ts`: ```ts interface SidebarRightTabDefinition { id: string; // 实现身份,唯一;body/title 在这个 key 下注册 kind: string; // 类型判别符 multiple?: boolean; // 省略 = 每个 pane 只保留一个页面 keepMounted?: boolean; patterns?: readonly string[]; // 资源地址 glob;页面类型省略 priority?: 'extension' | 'builtin' | 'fallback'; // 默认 extension canOpen?: (address: string) => boolean; title: (address: string) => string; guide?: readonly { id: string; order: number; title: () => string; description?: () => string; icon?: ComponentType }[]; } ``` - 一个 kind 可以同时有一个 `builtin` 和一个 `extension`,**extension 生效**。 - 同一 band 内重复注册、或 id 重复 → 抛错。 - 默认页规则:恰好一个 guide 入口时直接打开该页;0 个或多个时打开引导页。 - 布局存 `localStorage` 键 `dsh.sidebar-right.v1.`。 **第二段**——正文注册进 keyed 席位: ```js slots.inject("sidebar.right.pane.tab", () => slots.register({ name: "sidebar.right.pane.tab", key: id }, Body)); ``` `sidebar.right.pane.tab` 的 `children` 声明在 `ui-sidebar-right` 里带了 `inject: { hooks: { tabInfo: tabInfoFactory } }`,所以**正文组件自动收到 `useTabInfo` prop**,无需自己 inject。`useTabInfo()` 返回: ```js { sidebar: { expanded, fullscreen }, panel: { id }, tab: { ...record, visible, navigation, signal, actions, refreshShortcut } } ``` `tab.actions` 有 `openResource(address, options?)` / `openTab(kind, options?)` / `close()` / `bindCommands(commands)`。 **打开 tab**:`ctx.sidebarRight.openTab(kind, options?)`(跨插件服务面, `lib/types/client/service.d.ts` 的 `ISidebarRight`)。**同一步会自动展开 右侧栏**——"content the user cannot see is not opened"。 ## 文件预览怎么走 点文件时把绝对路径折成 `dsh-resource://file/session//<相对路径>` 交给 `tab.actions.openResource(...)`,官方 `dsh-client-ui-sidebar-documentpreview` 接管渲染(高亮、折行、图片、Markdown)。地址构造逻辑抄自 `@deepseek-ai/dsh-client-ui-sidebar-files/lib/client.js` 的 file-address 段: ```js const FILE_ADDRESS_PREFIX = "dsh-resource://file/"; encodeSegment = (s) => encodeURIComponent(s).replace(/%3A/gi, ":"); encodePath = (p) => p.split("/").map(encodeSegment).join("/"); ``` 一定要带 `//`:`new URL("dsh-resource://file")` 会把裸 `file` 当主机名丢掉。 ## host 半边的 RPC 走 `connection.fetch.register`(认证 Fetch 路由,与 `/api/agent-preset.export` 同机制),**不需要 `@deepseek-ai/dsh-typert-protocol`**: ``` POST /api/desktopProject/ body: { type: "client-request", rpcId, method: "desktopProject/", payload: { args: { ...} } } resp: { ok: true, value } | { ok: false, error: { code, message, detail } } ``` 已实现(共 18 个): `listProjectWorkspaces`、`listProjectEntries`、`projectReadFile`、`projectWriteFile`、 `projectCreateEntry`(可带 `content`,见下面的模板一节)、`projectRenameEntry`、`projectMoveEntry`、`projectDeleteEntry`、 `projectSearchEntries`、`projectGrepFiles`、`projectLint`(见「推送前静态校验」一节)、 `projectPushStatus`(会连校验一起算,见「徽章上的校验计数」一节)、 `projectMarkPushed`、`projectResetPushState`、 `projectListScripts`、`projectRunScript`、`projectRunScriptPoll`、`projectRunScriptStop` (后四个见「『脚本』页签」一节)。 **曾经有过 `projectCopyEntry`**(连同 client 的复制 / 粘贴一起在 2026 年那轮被撤掉了)。 它当时是 `projectMoveEntry` 的复制版,唯一区别是重名不报错、靠 `freeName()` 往后找空位 (`a.txt` → `a (2).txt`),目录用自写的 `copyTree()` 递归、**不用**实验性的 `fs.cp`。 要恢复的话按这个形状写回去,注意 `copyFile` 的 import 也已经一起删了。 **路由是一次性注册的**:`apply()` 里按 `Object.keys(HANDLERS)` 建表挂上去。 所以往 `HANDLERS` 里加 RPC 之后**必须重启 `dsh web`**,光刷新页面拿到的还是旧路由 —— 症状是新 RPC 返回 `HTTP 404`,而旧的照常能用。 **连通性自检**:拿一个已注册工作区内的路径调 `listProjectEntries`;报 `path-outside-workspace` 就说明路由通了(这是应用层错误,不是 404)。 ## 路径边界 一切路径必须落在 `ctx.workspaceRegistry.list()` 给出的某个工作区内,否则抛 `path-outside-workspace`。比较前统一分隔符并在 win32 上转小写。 `projectWriteFile` 额外禁止把工作区根当文件写。 **所以用户的工程目录必须先在 DSH 里注册成工作区**,否则面板看不见它。 ## 环境事实 - host RPC 或 client bundle 改动后**必须重启 `dsh web`**;`dsh` CLI 没有 reload 子命令,client 插件清单在启动时固化。 - 真实安装路径前缀(**不在 `profiles/web/node_modules` 下**): `\…\.pnpm\@deepseek-ai+dsh-web-app@<版本哈希>\node_modules\@deepseek-ai\` - profile 备份:`<用户目录>\.dsh\profiles\web\package.json.bak-before-project-panel`、 `cordis.patch.yml.bak-before-project-panel`。 ## 走过的弯路(别再踩) 1. **注册 `main` + `sidebar.panellist`** → `ctx.layout.selectPanel(id)` 把中栏 整块换成插件面板,用户的会话和底部输入框全没了。 2. **注册 `shell.overlay` 做浮层** → `dsh` 的 overlay 层级里 `position: fixed` 不可靠,主区被挤窄;窄浮层里树 300px + 详情区只剩 20px,文件基本没法看。 3. **用 `ctx.remote.$mount`** → 要走构建期生成的 typed contribution,运行期 手写不出来,真实浏览器里以 `descriptor.parameters is not iterable` 崩掉。 必须走 fetch 信封。 4. **手写 `git clone` 上游桌面端插件直接装** → 上游锚 `0.1.2-alpha.5`,且 `dsh-agent-presets` 在 0.2.0 已改名 `dsh-agent-preset-registry`。 ## 输入框引用(工程树 → `@` 引用) 树里每个条目(文件、目录都有)行尾一个「引用」按钮:点它把一条引用 token 插进 会话输入框。走官方输入面,**不要自己注册 `@` source**(会和官方 `dsh-client-ui-reference` 抢同一个 trigger,同 trigger 重复注册直接抛错): ```js const span = inputActions.captureInsertion(); // 带 draftRev 做 CAS inputActions.insertText(text, span); // 一个撤销步 ``` `inputActions` 由 ui-conversation 通过 `ctx.uiSession.provide({ props: ["inputActions"] })` 登记(`dsh-client-ui-conversation/lib/client.js:18126-18142`),再经 `UiSession.adapter` 按 scope 自动注入到**所有 session scope 的 slot 组件**,`sidebar.right.pane.tab` 就在其中 —— 不必在 `slots.register` 里声明 inject。取不到时退到剪贴板 + 提示。 **格式逐字照抄官方 grammar**(`dsh-client-ui-reference/lib/client.js:17-23` 的 `formatFileMention`),否则装饰匹配不上、显示成普通文本: - 目录补尾斜杠:`@src/` - 含空白走引号:文件 `@"a b.ts"`;目录 `@"a b/`(**故意不闭合**,光标停在下钻位) - 含控制字符或 `"` → undefined,放弃插入 **路径基准必须是会话工作目录**(`useSessions((s) => s.byId[sessionId]?.cwd)`), 与官方文件树、预览地址同一个根。改用面板自己选的目录当基准,一旦两者不同, 预览地址就退化成带盘符的绝对路径 → 「文件不存在,可能已被移动或删除」。 实测:目录引用同样被渲染成蓝色可点击芯片 ✓ **「引用 / 查看源码」两个入口必须同判据。** 行尾那两个小按钮(「源码」「引用」)和 右键菜单里同样的两项,用的是同一套判断: - 「查看源码」只给文件(目录没有源码可看,走进编辑器只会弹「无法预览」); - 「引用」不给工作区根(引一个根没有意义)—— 判据就是 `relativeUnder(cwd, entry.path) !== ""`,和行尾按钮**一模一样**。 用户提过「右键的时候把引用和查看源码也放进去吧,这样子更方便操作」—— 右键是这类 面板里最顺手的入口,但复制一份逻辑就等于埋一个分岔点:哪天行尾改了判据、菜单忘了 改,用户看到的就是「按钮有、菜单里没有」。所以两处共用同一个 helper、同一个判据, `treemenu.mjs`(27 项)专门钉这条 —— 文件菜单 8 项、目录菜单 7 项、工作区根既无 「引用」也无「查看源码」、空白处只剩新建 + 推送状态那几项。 菜单位置:文件在「新建文件 / 新建文件夹」之后、「编辑」之前(**读在改前面**); 目录在「新建文件 / 新建文件夹」之后、「推送前校验」之前。 ## 源码页签与多标签 面板只有两个页签:**文件树** / **源码**(`view` 状态)。源码页签里可以**同时开多个 文件**,顶部一条标签条来回切 —— 状态是数组: ```js const [shots, setShots] = useState([]); // { mode, status, path, name, content, error, withBody, anchor, focus, dragging } const [shotAt, setShotAt] = useState(-1); // 当前标签下标,-1 = 一个都没开 const snippet = shotAt >= 0 && shotAt < shots.length ? shots[shotAt] : null; ``` - `mode: "code"` —— 选行视图,拖选行区间引 `@文件 L起-止`。 - `mode: "edit"` —— 面板内文本编辑器,带「写回」。**现在有主动入口了** (见下面「面板内编辑文件」);它最初只是委托官方预览器失败时的兜底 (工作区外的文件,或组件拿不到 `tab.actions`)。 - `setSnippet(updater)` 保留成原单例 setter 的形状(改当前标签,传 `null` 关掉它), 拖选、勾选、编辑器输入这些调用点不用挨个改。 - `patchShot(path, patch)` 按路径改标签:`projectReadFile` 回来时下标可能已经被挪过。 - 关掉标签后由一个 effect 把 `shotAt` 夹回合法范围;一个都不剩就退回文件树。 - 换工程目录(`dir` 变)时所有标签作废 —— 它们记的是旧根下的相对路径。 ### 行号引用 `insertRange()` 取 `Math.min/max(anchor, focus)` 算出 `L12` 或 `L12-25`,拼成 `@相对路径 L12-25 ` 一次插进输入框。勾了「带行内容」还会把这几行的原文贴成代码块 —— 只给行号的话,官方引用芯片投喂的是整份文件,AI 得自己数行,常常数不准。 **拖出去的行号以浏览器原生选区为准**(`selectionLines()`),不是我们记的 `anchor`: 先拉选区、再按住选区往外拖时,那次 `mousedown` 会把 `anchor` 重置,只有原生选区 还留着真实范围。所以行上**不能**加 `userSelect: none`。 ## 状态保持(两处坑) 1. **tab 注册必须写 `keepMounted: true`。** 点树里的文件会把当前 tab 换成官方 documentpreview,切回「工程」时如果组件已被卸载,展开的树、拉过的层级、 所有源码标签全部归零。默认(省略)是**不**保活。 2. **每个工程目录各留一份浏览状态**(`dirStatesRef`,key 是 `dir`)。 换目录时先把当前这份存进去,再看新目录有没有缓存 —— 有就直接接上, 没有才清空重拉。没有这层缓存的话,切到工作区 B 再切回 A,A 的树和 源码标签都会被重置。 `dirStatesRef` 的存取放在监听 `dir` 的 effect 里,并且**靠 `prevDirRef` 判断 是不是真的换了目录**:`loadLevel` 换身份也会让 effect 重跑,那种情况必须原样返回, 否则会把用户刚展开的状态又覆盖回缓存里的旧快照。 ## 文件的增删改 + 移动 树里**没有任何新建按钮**,一律右键:目录上右键建在它**里面**,文件上右键建在它**旁边**, 空白处右键算工程根。菜单项 = 新建文件 / 新建文件夹 / 重命名 / 移动到… / 删除 (后四项只在条目上出现)。 重命名和删除另有键盘入口(`F2` / `Delete`,见下面的「键盘导航」), 但**菜单仍然是唯一能新建和移动的入口**。 ```js onContextMenu: (event) => { event.preventDefault(); openMenu(event, entry); } ``` - 新建的**输入行长在目标那一层的开头**(`renderLevel` 里按 `newAt.parent === path` 插一行), 不再有顶部工具条 —— 工具条那种全局入口在多级目录下根本看不出会建到哪个文件夹。 - 菜单用 `position: absolute`、坐标相对 `CSS.body`(`bodyRef`),**不要 `fixed` + `clientX`**: 外层一旦有 transform 就会偏。贴底时把 `y` 往上顶,免得被面板裁掉。 - 关菜单用 **window `mousedown`(capture)监听**:命中 `menuRef.current.contains(target)` 放过, 点别处关掉。菜单项自己的 `onClick` 在 `mousedown` 之后跑,所以不会误关。 - 右键**收着的**目录也要先 `setExpanded` 展开,否则输入行没地方长。 - `renderTree` 的未就绪 / 报错 / 空目录三个分支也套同一层带 `onContextMenu` 的容器 —— 空目录里一行都没有,不然连新建都点不到。 ### 新建文件时的模板(`templateFor`) 新建文件会按扩展名套一份骨架 —— 表放在 **client**(`lib/client.js` 的 `FILE_TEMPLATES`), **不是 host**:它是纯静态数据,为它加一个 RPC 不值当。 判据是**文件名最后一个点之后**那段:`lastIndexOf(".") > 0` 且不是结尾的那个点。 所以 `.env` / `.gitignore` 这类「点开头、后面再没有点」的整名不算扩展名,和 host `freeName()` 判「有没有扩展名」是同一个口径;扩展名一律 `toLowerCase()` 比(`UPPER.HTML` 也认)。 表的值可以写成**函数**,参数是去掉扩展名的文件名 —— `.md` 拿它当一级标题:`README.md` → `# README`。 **内容跟创建放在同一次写里**:`projectCreateEntry` 多了一个可选 `content`,client 挑好模板当 第 4 个实参传过去(没命中传 `""`,就是原来那个空文件)。**不要改成「建完再补一次写」** —— 中间会留下一个空文件,文件监视器和 git 都会看到两次写入,补内容那一步失败时还多出一个 本不该存在的空壳。 目前有模板的扩展名:`html`/`htm`、`vue`、`json`、`sh`/`bash`、`svg`、`md`/`markdown`。 其余一律空文件 —— 模板该省掉的是「每次都得敲的那几行」,**不要为了凑数往里塞写上去 就会被删掉的样板**。 ### 移动 / 拖拽整理 两种入口,都落到同一个 `moveEntry(path, parent)`(`path` 是**源的那一条**,`parent` 是**目标目录**,不是目标全路径): - 右键「移动到…」→ `setMoveList(entry.path)` 进模式,之后**点哪儿就是搬到哪儿**,Esc 或提示条上的 「取消」退出。顶栏下面会出一条提示条 —— 进了模式却不提示的话,接下来每一次点击都变成搬家, 用户只会以为面板坏了。 - 直接把行拖到目标上松手。树行 `draggable`;目录行和文件行都是 drop 区(拖到文件上算落到 它的父目录),行之间的空隙落到工作区根。 **`dataTransfer.getData` 在 `dragover` 里读不到** —— 浏览器只在 `drop` 时才允许读。所以拖拽中的 源路径另存 `dragPathRef`(`useRef(null)`),`dragover` / `drop` 都只看它;`drop` 时用 `{ path: src, name: baseNameOf(src) }` 现造一个最小 entry 就够了。 搬到别处之后有两件事必须跟着做,漏一个都是 bug: 1. **刷新源和目标两边的层级**(`loadLevel(here)` + `loadLevel(parent)`)。只刷一边的话,旧位置 会留下一行幽灵:盘上已经没了,界面上还挂着。 2. **`remapShots(from, to)`** —— 把开着源码标签的路径前缀换掉,连同 `editorViewRef.current.path`。 不换标签还挂在旧路径上,点「写回」会往一个不存在的位置写,重则把文件在**旧地方重新创建出来**; 不换 ref 则挂载 effect 会以为「换文件了」而销毁重建,内容还在(`setSnippet` 改的本来就是 `shots`),但滚动位置和撤销栈全丢。 host 侧 `projectMoveEntry` 的拒绝理由:目标不是目录、目标已存在(`already-exists`)、把目录搬进 它自己里面(`bad-target`)、搬工作区根、跨盘符(`EXDEV` → `cross-device`)。最后一条是 `fs.rename` 的硬限制,Windows 上换盘符一定撞上,只能提示没法绕。 ### 键盘导航 | 键 | 作用 | | --- | --- | | `↑` `↓` | 上下走行(光标还没落过时:`↓` 从第一行、`↑` 从最后一行起) | | `Home` `End` | 跳到首 / 末行 | | `Enter` | 目录展开/收起;文件打开(和点击一致) | | `→` | 展开目录。文件不响应,**不白吃这个键** | | `←` | 展开着的目录收起;已经收着的(或文件)跳到父目录 | | `F2` | 行内改名(复用右键那套 `editPath` / `editName`) | | `Delete` | 删除 —— 走 `removeEntry`,目录仍会弹确认 | **监听挂在 `window` 上,不给树加 `tabIndex`。** 树行是普通 `div`,要让它收焦点就得 「先点一下、再按键」,手感很别扭。改成「只要焦点不在输入类元素里就接管方向键」: `INPUT` / `TEXTAREA` / `SELECT` / `isContentEditable` 一律不插手 —— 最后一条正好把 CodeMirror 也算进去了,在编辑器里打字不会触发导航。另外带 `alt` / `ctrl` / `meta` 的组合键 直接放行,不抢系统的。 **行上挂了 `data-path` / `data-kind` / `data-name`**,键盘处理按 DOM 顺序 `querySelectorAll("[data-path]")` 捞可见行,再从属性反查这一行是什么。不遍历 `nodes` 查表 —— DOM 顺序天然就是「展开树从上到下」的顺序,省一次查表也省一类不同步的 bug。 `host.offsetParent === null` 用来兜「切到源码页签、树被藏起来了」这种情况。 **`cursor` 和 `selected` 是两份 state,不能合并。** `selected` 是「当前打开的那个文件」 (写回、标签都认它),`cursor` 只是键盘走到哪儿了 —— 合并的话上下走一圈就会顺手把开着的 标签全换掉。三种行高亮也因此能同时存在、互不打架: - 整块底色 = `selected`(打开中) - 虚线轮廓 = `dragOver`(拖拽落点) - 左侧内阴影 = `cursor`(键盘光标) 光标失效的四个口子都补了:切工作区(`setCursor(null)`)、删除、改名(指向的路径已经不存在了, 按方向键会找不到它)、移动(跟着 `moved` 走)。漏掉不崩 —— `paths.indexOf(cursor)` 返回 -1, `↓` 从头、`↑` 从尾重新开始 —— 但光标会在视觉上凭空消失。 ### 首屏卡在「读取中」 `prevDirRef` 必须从 `null` 起步: ```js const prevDirRef = useRef(null); // 写成 useRef(dir) 就完了 ``` 写成 `useRef(dir)` 时,首屏那次 effect 里 `prev === dir` 当场 return, **第一次请求永远发不出去**,树一直停在「读取中」,非要点一下打开 / 刷新才行。 ## 搜索:文件名 + 文件内容(顶栏那个输入框) 顶栏原来是「工作区下拉 + 路径输入框 + 打开」三件套,已撤掉 —— 工作区就是会话 目录,没得选也不用选;真正麻烦的是树只能一层层点。现在顶栏是: ``` [ 搜索… ] [文件名] [内容] [清空] [刷新] ``` 两个模式不是自动降级的,用户按 `文件名` / `内容` 自己选(选中态 = `CSS.modeOn`)。 **理由是代价差一个量级**:文件名搜索只翻目录,内容搜索要真读每个文件; 自动降级会让「怎么突然卡了两秒」变得无法解释。 ### 文件名模式(`projectSearchEntries`) - **host RPC** `projectSearchEntries({ path, query })` → `{ hits, truncated }`, 每项 `{ name, relPath, path, kind, score, hidden }`,已按 `score` 降序排好。 - **打分是子序列匹配**(`fuzzyScore`,host 侧 `lib/index.js`):needle 的字符按顺序 出现在**相对路径**里就算命中,不要求连续 —— `dsprjp` 能命中 `dsh-project-panel/lib/client.js`。加分项:连续命中(streak)、命中位置靠前、 整条路径短。实测 `clientjs` 让 `lib/client.js`(98) 排在 `lib/client.editor.js`(95) 前面。 打分对象是 `relPath` 而不是文件名,所以带目录的关键字(`libcli`)也能用。 - 上限 `MAX_SEARCH_HITS = 200`;超了返回 `truncated: true`,client 提示写细关键字。 ### 内容模式(`projectGrepFiles`) - **host RPC** `projectGrepFiles({ path, query, ignoreCase })` → `{ hits, files, matched, skipped, truncated }`。`hits` 每项 `{ name, relPath, path, line, column, text }`,`line` / `column` 都是 **1 基**。 - **纯字面量匹配,不做正则**:用户十次有九次找的是一段普通文本,让正则悄悄吃掉 `(` 或 `.` 反而更难解释。`ignoreCase` 默认 `true`(client 固定传 `true`)。 - **`text` 会从左边切一段**(`clipLine`):命中列号超过 80 时从 `hitAt - 60` 开始截 200 字符,两头补 `…`。`line` / `column` 仍是真实位置,只有展示被裁了 —— 不然命中在长行尾部时预览里根本看不见命中本身。 - **二进制靠嗅 NUL 判断**(`looksBinary`):只看前 8 KB(`BINARY_SNIFF_BYTES`)。 按扩展名列白名单永远会漏,而图片 / exe / 压缩包一读一个准。 - 上限:`MAX_GREP_HITS = 400`、`MAX_GREP_FILES = 3000`、 `MAX_GREP_FILE_BYTES = 512 KB`(超了算 `skipped` 而不是 `truncated`)、 `MAX_GREP_LINE_CHARS = 200`。摘要行会报「读了 N 个文件,M 个文件命中 K 行, 跳过 X 个(太大或是二进制)」,所以被跳过的量用户看得见。 - **client 侧渲染是「文件 → 行」两层**(`renderGrepSearch()`):文件名占一行、 右侧标「N 行」,命中行缩进挂在下面(行号右对齐 + 等宽字体的 `text`)。 同一文件连续的行合并成一组,省掉重复文件名。 - **点命中行(或那个文件行尾的「源码」按钮)→ `openGrepHit(entry, line)` → `openLocal(entry, "edit", line)`**:在**面板自己的编辑器**里打开,并落在那一行。 这里故意开 `"edit"` 而不是 `"code"`(选行视图)—— 选行视图的行是它自己渲染的, 接不了「滚到第 N 行」;编辑器分片从 0.2.0 起认 `line`。看命中行本来也要语法高亮。 早先这条路走的是 `revealHit` → 官方预览器的 `params.line`(跳到右侧栏去), 在面板里搜完却要跑别处看,很别扭,所以换掉了。**文件名模式点文件行仍是 `revealHit`。** - **`params.line` 是 1 基**(`dsh-api-workspace-files/lib/types/client/types.d.ts:14` 原文: “1-based line to scroll into view; absent leaves the position alone”), `openFile(entry, line)` 就是往那里塞的。`openShotTab(entry, mode, line)` 的 `line` 同样 1 基,落到 shot 的 `caret` 字段上,只影响滚动位置,和选行引用的 `anchor`/`focus` 无关。 ### 两边共用的部分 - **遍历是同一个 `walkWorkspace(root, visit)`**(host 侧):DFS,跳过 `SEARCH_SKIP_DIRS` 和符号链接,`visit` 返回 `false` 就**立刻停整棵树** —— 两个调用方都靠它实现「命中到上限就收工」。`relPath` 一律 `/` 分隔。 改遍历规则只改这一处。 - **跳过重目录**:`SEARCH_SKIP_DIRS` = node_modules / .git / dist / build / release / out / coverage / target / \_\_pycache\_\_ / .venv / venv / .next / .nuxt / .cache。 不跳的话一个前端工程动辄几万条,面板会卡好几秒。递归深度 `SEARCH_MAX_DEPTH = 12`。 - **client 侧**:输入 250 ms 防抖(每敲一个字全量扫盘会卡手),`search` 非空时 `renderTree()` 改道 `renderSearch()` / `renderGrepSearch()` —— 搜索结果和树是 两套视图,不混着显示。文件名模式的行显示 `relPath`(命中可能在任意深度, 只给文件名认不出是哪个),点目录就地展开、点文件直接打开,行尾的「源码 / 引用」 和右键菜单与树行完全一致。 - **切模式时清掉对面那份结果**(`setGrep(null)` / `setSearchHits(null)`), 否则旧结果会在新模式的渲染分支里短暂闪一下。 - **没有手动切换工作区的入口,这是故意的**:面板的根跟着**会话的 cwd** 走 (`useSessions((s) => s.byId[sessionId]?.cwd)`),换会话就是换工作区。 曾经加过「空白处右键列出工作区」,用户明确否掉了 —— 和切会话重复,多一个入口 只会让人猜哪个才是生效的那个。`workspaces` 状态、`listProjectWorkspaces` 调用、 `switchWorkspace()` / `CSS.select` 一并删干净。**别再往菜单里加回来。** - 删掉 `draft` / `openDir` / `workspacePicker` 后**记得搜一遍残留引用**: `setDraft(cwd)` 还挂在 cwd 那个 effect 里,漏掉就是运行时错误。 - `walkWorkspace` 的 `visit` 允许是 async(内容搜索要 `await readFile`), 内部统一 `await visit(...)` —— 同步函数也一样能用。 ## CodeMirror 编辑器(分片,全插件唯一有构建步骤的一块) 编辑态默认是内置 `