From 839e83053b22c1125abe94c7f0c2985bd79d8262 Mon Sep 17 00:00:00 2001 From: betterer <54624517+better-er@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:45:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=A4=8D=E7=94=A8=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E6=9F=A5=E7=9C=8B=E5=99=A8=E4=B8=8E=20workspaceFiles?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E4=BF=9D=E7=95=99=E4=BF=9D=E5=AD=98=E7=AB=AF?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件默认交还 dsh 内置查看器,插件不再自动认领资源。正文经 dsh-resource://file/session 地址调 remote.workspaceFiles.readBytes 读取,文件树与 tab 复用内置能力,删掉自建的 describe、listDir、readFile 三个端点、覆盖层面板与文件树。UI 只留两个席位:内置文档预览头部的铅笔图标,以及 sidebar.right.pane.tab 上按类型 id 注册的 Monaco 正文。 写入仍走自注册的 /classic-coding writeFile,改用 Node 原生 fs.writeFile,不经 ctx.fs 的 agent 沙箱。 同时修三处接线错误:replaceTab 传布尔值让布局层抛 layout: tab true has no pane,改为传活动 tab 的 id;inject 缺 remote.workspaceFiles,正文一读文件就崩在 without inject;readBytes 返回的 RemoteResult 没解包,编辑器拿到空内容且完全不报错。 版本升到 0.2.3,README 同步新交互,release workflow 把 Smoke 挪到 Build 之后,冒烟脚本的 inject 期望一并更新。 --- .github/workflows/release.yml | 9 +- README.md | 45 +- package.json | 4 +- smoke/verify.mjs | 124 ++-- src/client.ts | 1311 ++++++++++----------------------- src/index.ts | 229 ++---- 6 files changed, 508 insertions(+), 1214 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8882ad1..87e41c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,15 +104,16 @@ jobs: if: steps.published.outputs.published != 'true' run: pnpm typecheck + - name: Build + if: steps.published.outputs.published != 'true' + run: pnpm build + # 编辑器面板与文件树的加载路径走冒烟脚本,单独跑一遍。 + # 冒烟脚本直接 import lib/ 产物,必须排在 Build 之后。 - name: Smoke if: steps.published.outputs.published != 'true' run: pnpm smoke - - name: Build - if: steps.published.outputs.published != 'true' - run: pnpm build - # 本插件是双半身且带 sourcemap,产物缺失会在此失败。 - name: Verify build outputs if: steps.published.outputs.published != 'true' diff --git a/README.md b/README.md index 3893c45..701af0b 100644 --- a/README.md +++ b/README.md @@ -4,37 +4,13 @@ 为了在 AI 编程的高度发展的今天,保护古法编程这一非物质文化遗产,dsh·古法编程插件被开发出来,让你可以在 DeepSeek Harness 中继续体验大脑的思考能力。 -在 DSH 对话界面右侧滑出一个 Monaco 编辑器 + 文件树面板,直接在对话里改本地工作区代码,不用切到外部编辑器。 - ## 功能 -- **面板开关**:侧栏底部「⌨️ 古法编程」按钮,点击展开或收起编辑器面板。 -- **文件树**:左侧 250px,懒加载目录、支持折叠或展开,过滤 `.git`、`node_modules` 等。 -- **编辑器**:Monaco 编辑器经 CDN 动态加载,多文件 Tab、语法高亮、`Ctrl+S` 保存。 -- **状态栏**:底部显示当前文件路径和保存结果。 -- **关闭方式**:点击遮罩、按 `Esc` 或点 × 关闭面板,随 DSH 明暗主题自动适配。 -- **窄栏适配**:侧栏收起成窄 rail 时自动切换为单图标按钮,展开时恢复完整两行结构。 -- **消息流尾巴**:工具行路径、产物区文件按钮后追加「古法编程」尾巴,点击尾巴在插件内打开文件,点击按钮本身保持原有外部打开行为。 - -## 截图 - -### 侧边栏开关 - -![侧边栏触发按钮](docs/screenshots/侧边栏开关.webp) - -侧栏底部的「⌨️ 古法编程」按钮,点击展开或收起编辑器面板。 - -### 消息流文件按钮的古法编程后缀 - -![消息流文件按钮后缀](docs/screenshots/古法编程后缀.webp) - -消息流中工具行路径与产物区文件按钮后追加的「古法编程」尾巴:点击尾巴在插件内打开,点击按钮本身保持外部打开。 - -### 编辑器面板 - -![编辑器面板](docs/screenshots/编辑区大图.webp) - -右侧滑入的完整编辑面板:左侧文件树、顶部多文件 Tab、中部 Monaco 编辑器、底部状态栏。 +- **原生查看打底**:文件默认完全交给 dsh 内置查看器,插件不参与自动认领,Markdown、图片、PDF 与代码预览都保持原样。 +- **按需进入编辑**:内置文档预览头部出现铅笔图标,点击把当前文件切换为 Monaco 编辑模式;编辑模式工具栏里的「查看」按钮切回内置预览。 +- **复用内置文件树与 tab**:不再自建文件树与覆盖层面板,编辑 tab 与内置 tab 一起分栏、浮出、跨会话切换保活。 +- **编辑器**:Monaco 经 CDN 动态加载,按扩展名自动选语言,随 DSH 明暗主题实时切换。 +- **保存**:Ctrl+S 或工具栏「保存」按钮写回原文件。写入走插件自己的保存端点,不经过 agent 沙箱与权限链。 ## 演示视频 @@ -42,6 +18,8 @@ | :---: | | [![古法编程插件演示](https://i2.hdslb.com/bfs/archive/0d8bde731b526538d69ec983883a4bf755d6cf81.jpg)](https://www.bilibili.com/video/BV1d28i6rEEz/) | +> 视频录制于旧版覆盖层面板时期,交互入口已改为 dsh 内置右侧栏 tab。 + ## 安装 **从 GitHub 安装**:源码在 `src/`,`lib/` 不入仓库,安装时 npm 会触发 `prepare` 脚本现场构建。 @@ -70,12 +48,13 @@ dsh plugin --profile web remove dsh-classic-coding - **标准形态**:dsh 客户端插件,声明 `dsh.client`、导出 `./client`。 - **自挂载 bundle**:同时声明 `dsh.bundle`,用 `dsh plugin --profile web add` 从 GitHub 安装后自动识别为 profile layer 并挂载,无需手工写组合 entry。 -- **独立 RPC 通道**:Host 端经 `mountRpcChannel` 直接向 `webServer` 自注册 `/classic-coding` 前缀路由,复用 `connection.requestRejection` 做 Host 校验与浏览器鉴权,并实现官方 `client-request` / `server-response` 信封。dsh 0.1.5 的 `connection.rpc.handle` 在登记路由时解析 `webServer` 会抛 without inject,因此自行注册 prefix 路由。注册 `describe` / `listDir` / `readFile` / `writeFile` 四个端点,提供文件树与编辑器读写,底层走 DSH 内置 `ctx.fs` 文件系统服务。 -- **构建产物**:源码在 `src/`,用 tsdown 构建出 `lib/index.js`、`lib/index.d.ts` 与 `lib/client.js`。`lib/` 不入库,从 GitHub 安装时由 `prepare` 自动构建;本地开发跑 `pnpm install` 后 `pnpm build`,类型检查用 `pnpm typecheck`。`package.json` 声明 `dsh.client.platform: "web"`、`exports["./client"] → ./lib/client.js`。 -- **UI 挂载点**:`sidebar.footer.action` 触发按钮、`shell.overlay` 编辑器面板两个插槽注入。 +- **读取复用内置**:文件内容与目录列举交给 `@deepseek-ai/dsh-api-workspace-files`,正文经地址 `dsh-resource://file/session/<会话>/<路径>` 调 `remote.workspaceFiles.readBytes` 读取,文件树用内置 `files` 类型,插件不再自建 describe / listDir / readFile。 +- **写入自带端点**:内置服务不提供修改操作,故 Host 端经 `mountRpcChannel` 向 `webServer` 自注册 `/classic-coding` 前缀路由,只保留 `writeFile` 一个端点,复用 `connection.requestRejection` 做 Host 校验与浏览器鉴权,并实现官方 `client-request` / `server-response` 信封。保存刻意走 Node 原生 `fs.writeFile`,不经 `ctx.fs` 的 agent 沙箱。 +- **构建产物**:源码在 `src/`,用 tsdown 构建出 `lib/index.js`、`lib/index.d.ts` 与 `lib/client.js`。`lib/` 不入库,从 GitHub 安装时由 `prepare` 自动构建;本地开发跑 `pnpm install` 后 `pnpm build`,类型检查用 `pnpm typecheck`,产物冒烟用 `pnpm smoke`。`package.json` 声明 `dsh.client.platform: "web"`、`exports["./client"] → ./lib/client.js`。 +- **UI 挂载点**:`ctx.sidebarRightTabs.register` 注册 tab 类型 `code`,不写 `patterns`,因此不参与自动认领,只能由 `sidebarRight.openResource(address, { kind: 'code' })` 点名打开;`sidebar.right.tab.document.actions` 提供内置预览头部的「编辑」图标,`sidebar.right.pane.tab` 按类型 id 注册正文。不再占用 `sidebar.footer.action` 与 `shell.overlay`。 - **样式**:DSH 主题 CSS 变量 `--dsw-alias-*`,明暗主题自适应,`data-ds-dark-theme` 属性变化时实时跟随切换 Monaco 主题。 - **编辑器加载**:Monaco Editor 经 CDN 动态加载,React 组件从 loader 的 module table 获取,不引入任何额外 npm 依赖。 ## License -[MIT](./LICENSE) \ No newline at end of file +[MIT](./LICENSE) diff --git a/package.json b/package.json index 7130445..6508d63 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-classic-coding", - "version": "0.2.2", - "description": "「DSH·古法编程」:在 DSH Web UI 中提供 Monaco 编辑器 + 文件树覆盖层面板", + "version": "0.2.3", + "description": "「DSH·古法编程」:在 DSH Web UI 右侧栏用 Monaco 编辑器打开工作区代码文件", "keywords": [ "deepseek-harness", "dsh", diff --git a/smoke/verify.mjs b/smoke/verify.mjs index 92fd01c..1645e25 100644 --- a/smoke/verify.mjs +++ b/smoke/verify.mjs @@ -1,11 +1,11 @@ /** - * 产物冒烟验证:host 端 RPC 行为 + client 端模块注册。 + * 产物冒烟验证:host 端保存端点 + client 端 tab 类型注册。 * * 前置:先执行 pnpm build 生成 lib/,本脚本直接消费构建产物。 * 用法:pnpm smoke */ import { apply as hostApply, name as hostName, inject as hostInject } from '../lib/index.js' -import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises' +import { mkdtemp, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { readFileSync } from 'node:fs' @@ -23,14 +23,9 @@ function check(label, cond, extra) { // ─── host 端 ─────────────────────────────────────────── console.log('[host] 导出面') check('name', hostName === 'dsh-classic-coding', hostName) -check('inject', Array.isArray(hostInject) && hostInject.join(',') === 'fs', hostInject) +check('inject 为空,读写交给内置与自注册通道', Array.isArray(hostInject) && hostInject.length === 0, hostInject) const root = await mkdtemp(join(tmpdir(), 'cc-verify-')) -await writeFile(join(root, 'b.txt'), '内容B', 'utf8') -await writeFile(join(root, 'a.txt'), '内容A', 'utf8') -await writeFile(join(root, '.hidden'), 'x', 'utf8') -await mkdir(join(root, 'sub')) -await mkdir(join(root, 'node_modules')) let routeHandler = null let rejection = undefined @@ -39,15 +34,8 @@ const scope = { webServer: { register(route) { routeHandler = route.handler; return () => {} } }, effect(cb) { return cb() }, inject(deps, cb) { cb(scope) }, - sessions: { get: (id) => (id === 'live-1' ? { header: { cwd: root } } : id === 'live-empty' ? { header: {} } : undefined) }, - sessionPersistence: { stat: async (id) => (id === 'cold-1' ? { header: { cwd: root } } : undefined) }, } const ctx = { - fs: { - resolve: async (p) => p, - processPath: (t) => t, - readText: async (t) => readFile(t, 'utf8'), - }, inject(deps, cb) { cb(scope) }, } hostApply(ctx) @@ -90,30 +78,6 @@ function rawRequest(port, head) { }) } -const describeLive = (await rpc('describe', { sessionId: 'live-1' })).body.result -check('describe live', describeLive.ok === true && describeLive.value.root === root, describeLive) -const describeCold = (await rpc('describe', { sessionId: 'cold-1' })).body.result -check('describe cold', describeCold.ok === true && describeCold.value.root === root, describeCold) -const describeMiss = (await rpc('describe', { sessionId: 'nope' })).body.result -check('describe 未知会话报错', describeMiss.ok === false && /会话不存在/.test(describeMiss.error.message), describeMiss) -const describeEmpty = (await rpc('describe', { sessionId: 'live-empty' })).body.result -check('describe 空 cwd 报错', describeEmpty.ok === false && /工作目录缺失/.test(describeEmpty.error.message), describeEmpty) -const describeNoId = (await rpc('describe', {})).body.result -check('describe 缺 sessionId 报错', describeNoId.ok === false && /缺少 sessionId/.test(describeNoId.error.message), describeNoId) - -const list = (await rpc('listDir', { path: root })).body.result -check('listDir 成功', list.ok === true, list) -if (list.ok) { - const names = list.value.entries.map((e) => e.name) - check('listDir 过滤隐藏与 node_modules', names.join(',') === 'sub,a.txt,b.txt', names) - check('listDir 目录优先', list.value.entries[0].type === 'directory', list.value.entries[0]) -} -const listRel = (await rpc('listDir', { path: 'relative/x' })).body.result -check('listDir 拒绝相对路径', listRel.ok === false && /绝对路径/.test(listRel.error.message), listRel) - -const read = (await rpc('readFile', { path: join(root, 'a.txt') })).body.result -check('readFile 内容', read.ok === true && read.value.content === '内容A', read) - const target = join(root, 'out.txt') const wrote = (await rpc('writeFile', { path: target, content: '写入成功' })).body.result check('writeFile 返回 ok', wrote.ok === true && wrote.value.ok === true, wrote) @@ -122,6 +86,11 @@ try { landed = (await readFile(target, 'utf8')) === '写入成功' } catch { lan check('writeFile 落盘', landed) const wroteBad = (await rpc('writeFile', { path: target })).body.result check('writeFile 缺 content 报错', wroteBad.ok === false && /缺少 content/.test(wroteBad.error.message), wroteBad) +const wroteRel = (await rpc('writeFile', { path: 'relative/x.txt', content: 'x' })).body.result +check('writeFile 拒绝相对路径', wroteRel.ok === false && /绝对路径/.test(wroteRel.error.message), wroteRel) + +const removed = (await rpc('describe', { sessionId: 'x' })).body.result +check('已下线的 describe 报 bad-request', removed.ok === false && removed.error.code === 'bad-request', removed) const unknown = (await rpc('no-such', {})).body.result check('未知端点 bad-request', unknown.ok === false && unknown.error.code === 'bad-request', unknown) @@ -129,16 +98,16 @@ check('未知端点 bad-request', unknown.ok === false && unknown.error.code === const notFound = await fetch('http://127.0.0.1:' + port + '/classic-coding/') check('空端点 404', notFound.status === 404, notFound.status) rejection = 401 -const unauth = await fetch('http://127.0.0.1:' + port + '/classic-coding/describe', { method: 'POST', body: '{}' }) +const unauth = await fetch('http://127.0.0.1:' + port + '/classic-coding/writeFile', { method: 'POST', body: '{}' }) check('未授权 401', unauth.status === 401, unauth.status) rejection = undefined -const plain = await fetch('http://127.0.0.1:' + port + '/classic-coding/describe', { +const plain = await fetch('http://127.0.0.1:' + port + '/classic-coding/writeFile', { method: 'POST', headers: { 'content-type': 'text/plain' }, body: '{}', }) check('非 JSON content-type 返回 415', plain.status === 415, plain.status) -const oversized = await rawRequest(port, 'POST /classic-coding/describe HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: ' + (400 * 1024 * 1024) + '\r\n\r\n') +const oversized = await rawRequest(port, 'POST /classic-coding/writeFile HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: ' + (400 * 1024 * 1024) + '\r\n\r\n') check('超限请求体返回 413', /^HTTP\/1\.1 413/.test(oversized), oversized.split('\r\n')[0]) server.close() @@ -147,7 +116,6 @@ console.log('[client] 模块注册') const code = readFileSync(fileURLToPath(new URL('../lib/client.js', import.meta.url)), 'utf8') let entry = null let styleAppended = 0 -let observerCount = 0 const fakeElement = () => ({ dataset: {}, className: '', title: '', textContent: '', appendChild() {}, hasAttribute: () => false, setAttribute() {}, @@ -157,18 +125,12 @@ const documentMock = { head: { appendChild() { styleAppended++ } }, body: { hasAttribute: () => false }, createElement: fakeElement, - createTextNode: () => ({}), addEventListener() {}, removeEventListener() {}, querySelectorAll: () => [], } -class MutationObserverMock { - constructor() { observerCount++ } - observe() {} - disconnect() {} -} const windowMock = { __ModuleLoader__: { load(e) { entry = e } } } const sandbox = { - window: windowMock, document: documentMock, MutationObserver: MutationObserverMock, + window: windowMock, document: documentMock, fetch: () => Promise.reject(new Error('不该调用 fetch')), console, setTimeout, clearTimeout, setInterval, clearInterval, Promise, JSON, Object, Symbol, Error, RegExp, String, Number, Array, } @@ -189,39 +151,63 @@ const mod = entry.factory((id) => { }) check('默认导出为插件', mod.default && mod.default.name === 'dsh-classic-coding', mod.default && mod.default.name) check('具名导出 apply', typeof mod.apply === 'function') -check('具名导出 inject', Array.isArray(mod.inject) && mod.inject.join(',') === 'slots,sessions', mod.inject) +check('具名导出 inject', Array.isArray(mod.inject) && mod.inject.join(',') === 'slots,sidebarRightTabs,sidebarRight,remote,remote.workspaceFiles', mod.inject) const injections = [] const registrations = [] -let effectFn = null +const typeDefinitions = [] +let effectCount = 0 const clientCtx = { get(service) { if (service === 'slots') return { inject(n, cb) { injections.push(n); cb() }, register(opts, comp) { registrations.push({ opts, comp }); return () => {} }, } - if (service === 'sessions') return { list: { getSnapshot: () => ({ current: 'live-1' }) } } + if (service === 'sidebarRightTabs') return { + register(def) { typeDefinitions.push(def); return () => {} }, + } + if (service === 'sidebarRight') return { openResource() {}, active() { return undefined } } + if (service === 'remote') return { workspaceFiles: { readBytes: async () => ({ absolutePath: 'C:/x/a.ts', version: 'v', offset: 0, data: new Uint8Array(), eof: true }) } } return undefined }, - effect(cb) { effectFn = cb }, + effect(cb) { effectCount++; cb() }, } mod.apply(clientCtx) -check('注入两个槽位', injections.join(',') === 'sidebar.footer.action,shell.overlay', injections) -check('注册两个组件', registrations.length === 2, registrations.map((r) => r.opts.id)) -check('触发按钮 order 15', registrations[0]?.opts.order === 15, registrations[0]?.opts) -check('effect 已注册', typeof effectFn === 'function') -if (typeof effectFn === 'function') { - const dispose = effectFn() - check('样式已注入', styleAppended === 1, styleAppended) - check('尾巴 observer 已启动', observerCount === 1, observerCount) - check('effect 返回清理函数', typeof dispose === 'function') - dispose() - check('清理后移除样式', documentMock.head !== null) +check('注入正文与文档动作席位', injections.join(',') === 'sidebar.right.pane.tab,sidebar.right.tab.document.actions', injections) +check('注册两个组件', registrations.length === 2, registrations.map((r) => r.opts.key || r.opts.id)) +check('正文席位 key 与类型 id 一致', registrations[0]?.opts.key === 'dsh-classic-coding', registrations[0]?.opts) +check('文档动作席位 id', registrations[1]?.opts.id === 'dsh-classic-coding-edit', registrations[1]?.opts) +check('注册一个 tab 类型', typeDefinitions.length === 1, typeDefinitions) +const def = typeDefinitions[0] +if (def) { + check('类型 id', def.id === 'dsh-classic-coding', def.id) + check('类型 kind', def.kind === 'code', def.kind) + check('类型优先级 extension', def.priority === 'extension', def.priority) + check('类型保活', def.keepMounted === true, def.keepMounted) + check('不参与自动认领,默认走内置查看', def.patterns === undefined, def.patterns) + check('只认领会话地址', def.canOpen('dsh-resource://file/session/s1/a.ts') === true && def.canOpen('dsh-resource://file/absolute/C:/a.ts') === false) + check('标题取文件名', def.title('dsh-resource://file/session/s1/dir/a.ts') === 'a.ts', def.title('dsh-resource://file/session/s1/dir/a.ts')) } -for (const r of registrations) { - const el = r.comp() - check('组件可渲染 ' + r.opts.id, el && typeof el === 'object', el) +check('effect 已注册', effectCount === 2, effectCount) + +const bodyProps = { + useTabInfo: () => ({ + tab: { + navigation: { address: 'dsh-resource://file/session/s1/dir/a.ts' }, + signal: new AbortController().signal, + }, + }), } +const el = registrations[0]?.comp(bodyProps) +check('正文可渲染', el && typeof el === 'object', el && el.type) +const suspicious = registrations[0]?.comp({ + useTabInfo: () => ({ tab: { navigation: { address: 'sidebar://code' }, signal: new AbortController().signal } }), +}) +check('非文件地址落到错误提示', suspicious && typeof suspicious === 'object', suspicious && suspicious.type) +const action = registrations[1]?.comp({ absolutePath: 'C:\\CCC_nospace\\x\\a.ts', sessionId: 's1' }) +check('文档动作图标可渲染', action && typeof action === 'object', action && action.type) +const actionNoSession = registrations[1]?.comp({ absolutePath: 'C:\\x\\a.ts' }) +check('无会话时仍可点击,地址在点击时兜底', actionNoSession && actionNoSession.props.disabled === undefined, actionNoSession && actionNoSession.props) console.log(failed === 0 ? '\n全部通过' : '\n失败 ' + failed + ' 项') -process.exit(failed === 0 ? 0 : 1) \ No newline at end of file +process.exit(failed === 0 ? 0 : 1) diff --git a/src/client.ts b/src/client.ts index e599968..7b1dd70 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,106 +1,41 @@ /** * 古法编程 - Client 端 - * Monaco 编辑器 + 文件树覆盖层面板。 * - * 构建产物经 DSH 的 __ModuleLoader__ 机制注册为浏览器端插件, - * 侧边栏底部按钮触发覆盖层,覆盖层包含文件树、标签栏和 Monaco 编辑器。 + * 向 dsh 内置右侧栏注册 tab 类型 code:文件默认仍由内置查看器展示, + * 在内置文档预览头部点「编辑」图标才以 Monaco 打开。内容经内置 workspaceFiles 服务按会话读取, + * 保存经 Host 自注册的 /classic-coding writeFile 端点,因为内置服务不提供修改操作。 * * @module dsh-classic-coding/client */ import * as React from 'react' -/** 文件树条目。 */ -interface FileEntry { - name: string - type: 'directory' | 'file' - size: number -} - -/** 已打开的标签。 */ -interface OpenTab { - path: string - content: string - modified: boolean -} - -/** listDir 端点返回值。 */ -interface ListDirResult { entries: FileEntry[] } - -/** describe 端点返回值。 */ -interface DescribeResult { root: string } - -/** readFile 端点返回值。 */ -interface ReadFileResult { content: string } - -/** 会话列表快照:current 即侧栏选中会话。 */ -interface SessionsSnapshot { current?: string | null } - -/** 会话服务最小面。 */ -interface SessionsService { - list: { - getSnapshot(): SessionsSnapshot | null | undefined - subscribe?(listener: () => void): () => void - } -} - -/** 槽位服务最小面。 */ -interface SlotsService { - inject(name: string, callback: () => (() => void) | void): void - register( - options: { name: string; id: string; order?: number }, - component: () => React.ReactElement, - ): () => void -} +// ─── 常量 ─────────────────────────────────────────────── -/** 客户端插件上下文最小面。 */ -interface ClientContext { - get(service: string): unknown - effect(callback: () => () => void): void -} +/** 本插件在 tab 注册表里的类型身份,同时用作正文席位 key。 */ +const TYPE_ID = 'dsh-classic-coding' -/** 插件全局状态:跨组件共享的可变单例。 */ -interface PluginState { - isOpen: boolean - root: string | null - monacoLoaded: boolean - monacoLoading: boolean - /** dirPath → 条目列表 */ - files: Record - openTabs: OpenTab[] - activeTab: string | null - /** Monaco 未就绪时等待显示的文件路径,内容必达 */ - _pendingShowPath: string | null - /** dirPath → true */ - expandedDirs: Record - styleTag: HTMLStyleElement | null - sessionId: string | null - _linkTailStarted: boolean - _linkTailObserver: MutationObserver | null - _refresh: (() => void) | null - _setIsOpen: ((open: boolean) => void) | null - _openFile: ((path: string) => void) | null - _saveFile: ((path: string) => void) | null - _editorInstance: MonacoEditorInstance | null - _themeObserver: MutationObserver | null - _monacoPromise: Promise | null -} +/** tab 类型的判别名。内置已有 guide、text、files、browser、subagentchat,code 不撞名。 */ +const KIND = 'code' -// ─── 常量 ─────────────────────────────────────────────── +/** 会话文件资源地址前缀,与 util/workspace-path 的语法一致。 */ +const SESSION_FILE_PREFIX = 'dsh-resource://file/session/' -/** 文件扩展名到 Monaco 语言的映射 */ +/** 代码扩展名到 Monaco 语言的映射,键同时也决定本类型认领哪些地址。 */ const LANG_MAP: Record = { - js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', + js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', + ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript', py: 'python', go: 'go', rs: 'rust', java: 'java', - c: 'c', cpp: 'cpp', h: 'c', cs: 'csharp', - html: 'html', css: 'css', scss: 'scss', + c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp', cs: 'csharp', + html: 'html', css: 'css', scss: 'scss', less: 'less', json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', - md: 'markdown', xml: 'xml', sql: 'sql', sh: 'shell', - ps1: 'powershell', bat: 'bat', cmd: 'bat', + xml: 'xml', sql: 'sql', sh: 'shell', + ps1: 'powershell', bat: 'bat', cmd: 'bat', ini: 'ini', env: 'ini', + vue: 'html', svelte: 'html', } /** - * 根据文件扩展名获取语言。 + * 按扩展名取 Monaco 语言。 * @param path - 文件路径。 */ function langByPath(path: string): string { @@ -110,31 +45,126 @@ function langByPath(path: string): string { } /** - * 根据文件名获取图标。 - * @param name - 文件或目录名。 - * @param type - 条目类型。 + * 取路径最后一段,兼容 Windows 与 POSIX 分隔符。 + * @param path - 绝对或相对路径。 */ -function iconFor(name: string, type: 'directory' | 'file'): string { - if (type === 'directory') return '\uD83D\uDCC1' - const ext = name.split('.').pop() || '' - const map: Record = { - js: '\uD83D\uDFE8', jsx: '\uD83D\uDFE8', ts: '\u26A1', tsx: '\u26A1', - py: '\uD83D\uDC0D', go: '\uD83E\uDDA6', rs: '\u2699\uFE0F', - html: '\uD83C\uDF10', css: '\uD83C\uDFA8', - md: '\uD83D\uDCDD', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB', - sh: '\u2328\uFE0F', txt: '\uD83D\uDCC4', - } - return map[ext.toLowerCase()] || '\uD83D\uDCC4' +function basename(path: string): string { + const parts = path.split(/[\\/]/) + return parts[parts.length - 1] || path } /** - * 把含 data-* 的属性表收窄成 React 可接受的属性类型。 - * @param value - 属性表。 + * 由会话 id 与绝对路径构造会话文件地址。 + * + * 地址段逐段组件编码,盘符的冒号保留原样;Host 原样接收路径并自行解析, + * 因此 Windows 的反斜杠先转成正斜杠,POSIX 的前导斜杠保留。 + * @param sessionId - 授权会话。 + * @param absolutePath - 文件在宿主执行环境中的绝对路径。 + */ +function sessionFileAddress(sessionId: string, absolutePath: string): string { + const prefix = SESSION_FILE_PREFIX + encodeURIComponent(sessionId) + '/' + const segments = absolutePath.replace(/\\/g, '/').split('/').map(function (segment) { + return encodeURIComponent(segment).replace(/%3A/gi, ':') + }) + return prefix + segments.join('/') +} + +/** + * 从 dsh-resource 文件地址拆出会话 id 与相对路径。 + * + * 只认领 session 地址:absolute 地址没有授权会话,内置读取会以 unknown-workspace 失败。 + * 地址各段做过组件编码,盘符的冒号保留原样,故逐段解码。 + * @param address - 资源地址。 */ -function attrs(value: Record): React.HTMLAttributes { - return value as React.HTMLAttributes +function parseFileAddress(address: string): { sessionId: string; path: string } | null { + if (!address.startsWith(SESSION_FILE_PREFIX)) return null + const rest = address.slice(SESSION_FILE_PREFIX.length) + const slash = rest.indexOf('/') + if (slash <= 0) return null + const sessionId = decodeURIComponent(rest.slice(0, slash)) + const path = rest.slice(slash + 1).split('/').map(decodeURIComponent).join('/') + if (!sessionId || !path) return null + return { sessionId, path } +} + +// ─── Host 服务面 ──────────────────────────────────────── + +/** 远端调用的统一结果:ok 分支携带业务值,error 分支携带失败帧。 */ +type RemoteResult = + | { ok: true; value: T } + | { ok: false; error: { message?: string } } + +/** 内置 workspaceFiles 的重载读取:返回文件元数据与完整字节。 */ +interface WorkspaceFilesFace { + readBytes( + sessionId: string, + path: string, + options: { range?: { offset?: number; length?: number }; baseFile?: string }, + signal?: AbortSignal, + ): Promise> +} + +/** remote 命名空间最小面。 */ +interface RemoteService { + workspaceFiles: WorkspaceFilesFace +} + +/** 右侧栏导航控制器最小面。 */ +interface SidebarRightService { + /** 打开一个 dsh-resource 地址;点名 kind 时跳过其 glob 但保留 canOpen。 */ + openResource(address: string, options?: { kind?: string; replaceTab?: string; revealIfOpened?: boolean }): void + /** 活动 pane 的活动 tab;id 用于原地换掉它,contentId 即该 tab 的内容地址。 */ + active(): { id?: string; contentId?: string } | undefined +} + +/** 右侧栏 tab 类型注册表最小面。 */ +interface TabsService { + register(definition: { + id: string + kind: string + patterns?: string[] + priority?: 'extension' | 'builtin' | 'fallback' + canOpen?(address: string): boolean + title(address: string): string + keepMounted?: boolean + }): () => void +} + +/** 槽位服务最小面:keyed 席位按注册项 key 定址。 */ +interface SlotsService { + inject(name: string, callback: () => (() => void) | void): void + register( + options: { name: string; id?: string; key?: string; order?: number }, + component: (props: any) => React.ReactElement, + ): () => void +} + +/** 客户端插件上下文最小面。 */ +interface ClientContext { + get(service: string): unknown + effect(callback: () => (() => void) | void): void +} + +/** 框架注入正文组件的 tab 信息钩子,由 slot 声明里的 hooks.tabInfo 转换为 useTabInfo。 */ +interface TabBodyProps { + useTabInfo(): { + tab: { + /** 框架铸造的 tab 标识,用作原地替换的 replaceTab。 */ + id: string + /** 打开时记录的地址,对资源 tab 即 contentId。 */ + navigation: { address: string } + /** 仅在记录消失或插件卸载时中止。 */ + signal: AbortSignal + } + } } +/** 当前 remote 服务,由 apply 赋值,正文组件经闭包外的它读取文件。 */ +let remote: RemoteService | null = null + +/** 当前右侧栏导航控制器,由 apply 赋值,供文档头部动作与编辑器切换视图。 */ +let sidebarRight: SidebarRightService | null = null + // ─── RPC 调用 ────────────────────────────────────────── let rpcIdCounter = 0 @@ -146,13 +176,13 @@ let rpcIdCounter = 0 * @param method - 端点名。 * @param payload - 端点载荷。 */ -async function rpcCall(method: string, payload?: unknown): Promise { +async function rpcCall(method: string, payload: unknown): Promise { const rpcId = 'cc-' + String(++rpcIdCounter) const body = JSON.stringify({ type: 'client-request', rpcId: rpcId, method: method, - payload: payload || {}, + payload: payload, }) const res = await fetch('/classic-coding/' + method, { method: 'POST', @@ -170,160 +200,60 @@ async function rpcCall(method: string, payload?: unknown): Promise { throw new Error(errMsg) } -// ─── 插件状态 ────────────────────────────────────────── - -/** 当前会话服务,注入获取:sessions.list.current 即侧栏选中的会话 */ -let sessions: SessionsService | null = null - -const state: PluginState = { - isOpen: false, - root: null, - monacoLoaded: false, - monacoLoading: false, - files: {}, - openTabs: [], - activeTab: null, - _pendingShowPath: null, - expandedDirs: {}, - styleTag: null, - sessionId: null, - _linkTailStarted: false, - _linkTailObserver: null, - _refresh: null, - _setIsOpen: null, - _openFile: null, - _saveFile: null, - _editorInstance: null, - _themeObserver: null, - _monacoPromise: null, -} - -/** - * 触发主面板重渲染:所有直接修改全局 state 的调用点用它刷新 UI。 - * 面板未挂载时 state._refresh 为 null,安全跳过。 - */ -function refresh(): void { - if (state._refresh) state._refresh() -} - -// ─── 会话与路径 ──────────────────────────────────────── - -/** - * 当前会话 id 从注入的 sessions 服务读取 list.current, - * 不抓取 DSH 内部 localStorage。官方 API 的 current 与侧栏选择同步,切工作区即变。 - * 返回 null 表示尚无当前会话,页面刚加载或连接未建立时。 - */ -function getCurrentSessionId(): string | null { - if (!sessions) return null - const snap = sessions.list.getSnapshot() - const id = snap && snap.current - return id || null -} - -/** - * 相对路径即文件树内转绝对路径即 RPC 用,Windows 分隔符。 - * 已是绝对路径即盘符或 UNC 时原样返回:消息流尾巴传入的路径可能是绝对路径, - * 不能再用 root 拼接。 - * @param relPath - 文件树内相对路径或绝对路径。 - */ -function toAbs(relPath: string): string { - if (!relPath) return state.root || '' - const p = String(relPath) - if (/^[A-Za-z]:[\\/]|^\\\\/.test(p)) return p - return (state.root || '') + '\\' + p.replace(/\//g, '\\') -} - // ─── 样式注入 ────────────────────────────────────────── +/** 正文根节点撑满侧栏 tab 容器,工具栏固定、编辑器占余下空间。 */ const CSS = [ - /* 触发按钮:两行结构,图标+标题 / 描述。动作按钮无开关状态,故不设 pill 胶囊 */ - '.dsh-cc-trigger{display:flex;flex-direction:column;gap:5px;width:100%;min-width:0;padding:7px 9px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:transparent;cursor:pointer;text-align:left;color:var(--dsw-alias-label-primary);font-family:inherit;transition:background .12s ease,border-color .12s ease}', - '.dsh-cc-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}', - '.dsh-cc-trigger:active{background:var(--dsw-alias-interactive-bg-active)}', - '.dsh-cc-trigger-head{display:flex;align-items:center;gap:7px;min-width:0}', - '.dsh-cc-trigger-icon{display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center;font-style:normal;opacity:.85}', - '.dsh-cc-trigger-title{font-size:12.5px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', - '.dsh-cc-trigger-desc{font-size:11px;line-height:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}', - /* 侧面板容器:全屏透明遮罩 + 右侧面板 */ - '.dsh-cc-overlay{position:fixed;inset:0;z-index:100;display:none;pointer-events:none}', - '.dsh-cc-overlay[data-open="true"]{display:block}', - /* 半透明遮罩:点击关闭面板 */ - '.dsh-cc-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.3);pointer-events:auto;transition:opacity .2s ease}', - '.dsh-cc-backdrop:hover{background:rgba(0,0,0,.35)}', - /* 右侧面板主体 */ - '.dsh-cc-panel{position:absolute;top:0;right:0;bottom:0;width:72vw;max-width:1400px;min-width:400px;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#333);box-shadow:-4px 0 20px rgba(0,0,0,.15);pointer-events:auto;transform:translateX(0);transition:transform .2s ease}', - /* 头部 */ - '.dsh-cc-header{display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0}', - '.dsh-cc-header-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}', - '.dsh-cc-close{cursor:pointer;font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;background:transparent;border:none;color:inherit}', - '.dsh-cc-close:hover{background:var(--dsw-alias-interactive-bg-hover)}', - /* 标签栏 */ - '.dsh-cc-tabs{display:flex;overflow-x:auto;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0}', - '.dsh-cc-tab{display:flex;align-items:center;gap:5px;padding:6px 12px;font-size:12px;cursor:pointer;border-right:1px solid var(--dsw-alias-border-l1);white-space:nowrap;color:var(--dsw-alias-label-secondary);background:transparent;border-top:none;border-bottom:none;border-left:none;font-family:inherit}', - '.dsh-cc-tab:hover{background:var(--dsw-alias-interactive-bg-hover)}', - '.dsh-cc-tab[data-active="true"]{color:var(--dsw-alias-label-primary);font-weight:500;border-bottom:2px solid var(--dsw-alias-brand-primary)}', - '.dsh-cc-tab-modified{color:var(--dsw-alias-state-error-primary)}', - '.dsh-cc-tab-close{font-size:14px;padding:0 2px;border-radius:3px;opacity:.5;cursor:pointer;background:transparent;border:none;color:inherit;line-height:1}', - '.dsh-cc-tab-close:hover{opacity:1;background:rgba(255,0,0,.1)}', - /* 主体 */ - '.dsh-cc-body{display:flex;flex:1;overflow:hidden}', - /* 文件树 */ - '.dsh-cc-tree-panel{width:250px;min-width:150px;max-width:500px;overflow-y:auto;border-right:1px solid var(--dsw-alias-border-l1);flex-shrink:0}', - '.dsh-cc-tree-item{padding:3px 8px;font-size:13px;line-height:1.6;display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', - '.dsh-cc-tree-item:hover{background:var(--dsw-alias-interactive-bg-hover)}', - '.dsh-cc-tree-loading{padding:12px;font-size:13px;color:var(--dsw-alias-label-secondary)}', - /* 编辑器 */ - '.dsh-cc-editor-panel{flex:1;overflow:hidden;position:relative}', - '.dsh-cc-editor{width:100%;height:100%}', - '.dsh-cc-editor-placeholder{display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--dsw-alias-label-tertiary);font-size:14px}', - /* 状态栏 */ - '.dsh-cc-status{padding:4px 16px;font-size:11px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);flex-shrink:0}', - /* 响应式 */ - '@media(max-width:768px){.dsh-cc-panel{width:92vw;min-width:0}.dsh-cc-tree-panel{width:160px}}', - /* sidebar footer action 是行方向 flex 容器,多个插件按钮会被左右挤压;容器内含本插件按钮时转纵向排列 */ - '[class*="footerActions"]:has(button.dsh-cc-trigger){flex-direction:column;align-items:stretch}', - /* 侧栏收起时切换单图标模式,frame 根节点的 data-sidebar-collapsed 属性:隐藏标题与描述,按钮收成 36x36 图标方块,避免溢出窄 rail;尺寸与边缘样式对齐 DSH 原生「新建会话」图标按钮,36x36、radius 12px、透明边框,收起态 specificity 高于 .dsh-cc-trigger:hover,故 hover 时边框保持透明、仅背景变化 */ - '[data-sidebar-collapsed] [class*="footerActions"]:has(button.dsh-cc-trigger){align-items:center}', - '[data-sidebar-collapsed] button.dsh-cc-trigger{width:36px;height:36px;padding:0;align-items:center;justify-content:center;border-radius:12px;border-color:transparent}', - '[data-sidebar-collapsed] .dsh-cc-trigger-title,[data-sidebar-collapsed] .dsh-cc-trigger-desc{display:none}', - '[data-sidebar-collapsed] .dsh-cc-trigger-head{gap:0}', - /* 消息流 fileLink 后的「古法编程」尾巴 */ - '.dsh-cc-open-here{margin-left:6px;cursor:pointer;font-size:12px;color:var(--dsw-alias-brand-primary);text-decoration:underline dotted;white-space:nowrap;user-select:none}', - '.dsh-cc-open-here:hover{text-decoration:underline;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);border-radius:3px}', + '.dsh-cc-body{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-layer-1)}', + '.dsh-cc-toolbar{display:flex;align-items:center;gap:8px;flex:none;padding:4px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}', + '.dsh-cc-filename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}', + '.dsh-cc-save{flex:none;padding:2px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px}', + '.dsh-cc-save:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}', + '.dsh-cc-save:disabled{opacity:.45;cursor:default}', + '.dsh-cc-status{flex:none;max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}', + '.dsh-cc-editor{flex:1;min-height:0}', + '.dsh-cc-message{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;text-align:center;font-size:13px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}', + '.dsh-cc-edit-action{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}', + '.dsh-cc-edit-action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}', + '.dsh-cc-edit-action:disabled{opacity:.4;cursor:default}', ].join('\n') +/** 插件样式表元素,随插件生命周期存在。 */ +let styleTag: HTMLStyleElement | null = null + /** 注入插件样式表,幂等。 */ function ensureStyle(): void { - if (state.styleTag) return + if (styleTag) return const tag = document.createElement('style') tag.dataset.plugin = 'dsh-classic-coding' tag.textContent = CSS document.head.appendChild(tag) - state.styleTag = tag + styleTag = tag } /** 移除插件样式表。 */ function removeStyle(): void { - if (state.styleTag && state.styleTag.parentNode) { - state.styleTag.parentNode.removeChild(state.styleTag) + if (styleTag && styleTag.parentNode) { + styleTag.parentNode.removeChild(styleTag) } - state.styleTag = null + styleTag = null } // ─── Monaco Editor 加载 ──────────────────────────────── +/** Monaco 加载 Promise,重复调用复用。 */ +let monacoPromise: Promise | null = null + /** DSH 暗色主题标记:ui-theme 在 body 上设置 data-ds-dark-theme。 */ function isDarkTheme(): boolean { return document.body.hasAttribute('data-ds-dark-theme') } -/** 从 CDN 加载 Monaco,重复调用复用同一个 Promise。 */ +/** 从 CDN 加载 Monaco,已就绪或加载中时直接复用。 */ function loadMonaco(): Promise { - if (state.monacoLoaded) return Promise.resolve() - if (state.monacoLoading && state._monacoPromise) return state._monacoPromise - state.monacoLoading = true - - state._monacoPromise = new Promise(function (resolve, reject) { + if (window.monaco) return Promise.resolve() + if (monacoPromise) return monacoPromise + monacoPromise = new Promise(function (resolve, reject) { const script = document.createElement('script') script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js' script.onload = function () { @@ -331,771 +261,302 @@ function loadMonaco(): Promise { paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }, }) window.require(['vs/editor/editor.main'], function () { - state.monacoLoaded = true - state.monacoLoading = false resolve() }) } script.onerror = function () { - state.monacoLoading = false + monacoPromise = null reject(new Error('Monaco Editor 加载失败')) } document.head.appendChild(script) }) - return state._monacoPromise + return monacoPromise } -// ─── 文件树组件 ──────────────────────────────────────── +// ─── 编辑器正文 ──────────────────────────────────────── -/** 文件树条目 props。 */ -interface FileTreeEntryProps { - entry: FileEntry - parentPath: string - level: number - onEntryClick: () => void - onFileOpen: (path: string) => void +/** 内置文档预览头部动作的 owner 面:绝对路径必给,会话由 session scope 提供。 */ +interface DocumentActionProps { + absolutePath: string + sessionId?: string } /** - * 单个文件树条目,目录展开时递归渲染子条目。 - * @param props - 条目 props。 + * 内置文档预览头部的「编辑」图标:把当前文件在右侧栏切换为古法编程编辑模式。 + * + * 该席位的 owner 只交出绝对路径,会话由 scope 提供,故地址在此构造; + * 没有会话时禁用,不猜当前会话。 + * @param props - 文档动作 owner 面。 */ -function FileTreeEntry(props: FileTreeEntryProps): React.ReactElement { - const entry = props.entry - const parentPath = props.parentPath - const level = props.level - const onEntryClick = props.onEntryClick - - const fullPath = parentPath ? parentPath + '/' + entry.name : entry.name - const isDir = entry.type === 'directory' - const icon = isDir ? '\uD83D\uDCC1' : iconFor(entry.name, entry.type) - const paddingLeft = 8 + level * 16 - - // 目录展开/折叠状态 - const expanded = state.expandedDirs[fullPath] || false - const children = state.files[fullPath] || null - const [loading, setLoading] = React.useState(false) - - function handleClick(): void { - if (isDir) { - if (state.expandedDirs[fullPath]) { - delete state.expandedDirs[fullPath] - onEntryClick() // 触发重渲染 - } else { - state.expandedDirs[fullPath] = true - onEntryClick() // 先展开,再加载 - if (!children) { - setLoading(true) - rpcCall('listDir', { path: toAbs(fullPath) }).then(function (result) { - state.files[fullPath] = result.entries - setLoading(false) - onEntryClick() // 加载完成,触发重渲染 - }).catch(function (e: unknown) { - console.error('[classic-coding] listDir error:', e) - setLoading(false) - delete state.expandedDirs[fullPath] - onEntryClick() - }) - } - } - } else { - props.onFileOpen(fullPath) +function EditAction(props: DocumentActionProps): React.ReactElement { + function enter(): void { + if (!sidebarRight) { + console.error('[classic-coding] sidebarRight 服务不可用,无法进入编辑模式') + return } - } - - const items: React.ReactElement[] = [] - // 条目本身 - items.push(React.createElement('div', { - key: 'item-' + fullPath, - className: 'dsh-cc-tree-item', - style: { paddingLeft: paddingLeft + 'px' }, - title: fullPath, - onClick: handleClick, - }, - React.createElement('span', null, isDir ? (expanded ? '\u25BC ' : '\u25B6 ') : ''), - React.createElement('span', null, icon + ' '), - React.createElement('span', null, entry.name), - )) - - // 子目录内容 - if (isDir && expanded) { - if (loading) { - items.push(React.createElement('div', { - key: 'loading-' + fullPath, - className: 'dsh-cc-tree-loading', - style: { paddingLeft: (paddingLeft + 20) + 'px' }, - }, '加载中...')) - } else if (children) { - children.forEach(function (child) { - items.push(React.createElement(FileTreeEntry, { - key: child.name, - entry: child, - parentPath: fullPath, - level: level + 1, - onEntryClick: onEntryClick, - onFileOpen: props.onFileOpen, - })) - }) + // 活动 tab 的 contentId 就是当前预览的地址,最可靠;owner 只给绝对路径,故留构造作兜底。 + const active = sidebarRight.active() + const activeAddress = active && active.contentId && active.contentId.startsWith(SESSION_FILE_PREFIX) + ? active.contentId + : null + const address = activeAddress || (props.sessionId ? sessionFileAddress(props.sessionId, props.absolutePath) : null) + if (!address) { + console.error('[classic-coding] 无法确定当前文件地址', { active: active, sessionId: props.sessionId, absolutePath: props.absolutePath }) + return } - } - - return React.createElement(React.Fragment, null, items) -} - -/** 文件树 props。 */ -interface FileTreeProps { - onFileOpen: (path: string) => void -} - -/** - * 文件树根组件:跟随当前会话工作区加载根目录。 - * @param props - 组件 props。 - */ -function FileTree(props: FileTreeProps): React.ReactElement { - const onFileOpen = props.onFileOpen - const [, forceUpdate] = React.useState(0) - const [rootEntries, setRootEntries] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [error, setError] = React.useState(null) - - // 当前会话 id:作为重载依赖。侧栏切工作区后会话变更,触发重新 describe + listDir,树跟随工作区。 - // 会话未就绪时为 null:页面刚加载或连接未建立,显示等待并轮询,就绪后自动加载,绝不抛错崩溃。 - const sessionId = getCurrentSessionId() - const [pendingSession, setPendingSession] = React.useState(sessionId) - - // 订阅 sessions 列表:侧栏切工作区即当前会话变更时自动重载,面板保持打开也能跟随。 - React.useEffect(function () { - if (!sessions || !sessions.list || typeof sessions.list.subscribe !== 'function') return - return sessions.list.subscribe(function () { - const id = getCurrentSessionId() - setPendingSession(function (prev) { return prev === id ? prev : id }) - }) - }, []) - - React.useEffect(function () { - if (pendingSession) { - state.sessionId = pendingSession - // 根变化后清空旧的目录缓存与展开状态,避免串台 - state.files = {} - state.expandedDirs = {} - setLoading(true) - setError(null) - setRootEntries([]) - - rpcCall('describe', { sessionId: pendingSession }).then(function (result) { - state.root = result.root - return rpcCall('listDir', { path: result.root }) - }).then(function (result) { - setRootEntries(result.entries) - setLoading(false) - }).catch(function (e: unknown) { - console.error('[classic-coding] 初始化失败:', e) - setError(e instanceof Error ? e.message : String(e)) - setLoading(false) + try { + // replaceTab 要的是活动 tab 的 id:新 tab 接替它的格与格位,并顺手关掉它。 + sidebarRight.openResource(address, { + kind: KIND, + replaceTab: active && active.id ? active.id : undefined, }) - } else { - // 会话未就绪:每 500ms 重试,sessions 服务初始化完成后即加载 - const timer = setInterval(function () { - const id = getCurrentSessionId() - if (id) { - clearInterval(timer) - setPendingSession(id) - } - }, 500) - return function () { clearInterval(timer) } + } catch (e) { + console.error('[classic-coding] 进入编辑模式失败', address, e) } - }, [pendingSession]) - - function triggerUpdate(): void { - forceUpdate(function (n) { return n + 1 }) } - - if (!pendingSession) { - return React.createElement('div', { className: 'dsh-cc-tree-loading' }, '等待会话就绪...') - } - - if (loading) { - return React.createElement('div', { className: 'dsh-cc-tree-loading' }, '加载中...') - } - - if (error) { - return React.createElement('div', { className: 'dsh-cc-tree-loading' }, '文件树加载失败: ' + error) - } - - return React.createElement('div', null, - rootEntries.map(function (entry) { - return React.createElement(FileTreeEntry, { - key: entry.name, - entry: entry, - parentPath: '', - level: 0, - onEntryClick: triggerUpdate, - onFileOpen: onFileOpen, - }) - }), - ) -} - -// ─── 标签栏组件 ──────────────────────────────────────── - -/** 标签栏 props。 */ -interface TabBarProps { - onTabSelect: (path: string) => void - onTabClose: (path: string) => void -} - -/** - * 标签栏:渲染已打开文件,含修改标记与关闭按钮。 - * @param props - 组件 props。 - */ -function TabBar(props: TabBarProps): React.ReactElement { - return React.createElement('div', { className: 'dsh-cc-tabs' }, - state.openTabs.map(function (tab) { - const name = tab.path.split(/[\\/]/).pop() || '' - const isActive = tab.path === state.activeTab - return React.createElement('div', attrs({ - key: tab.path, - className: 'dsh-cc-tab', - 'data-active': isActive ? 'true' : 'false', - onClick: function () { props.onTabSelect(tab.path) }, - }), - React.createElement('span', null, iconFor(name, 'file') + ' '), - React.createElement('span', null, name), - tab.modified ? React.createElement('span', { className: 'dsh-cc-tab-modified' }, ' \u25CF') : null, - React.createElement('button', { - className: 'dsh-cc-tab-close', - onClick: function (e: React.MouseEvent) { - e.stopPropagation() - props.onTabClose(tab.path) - }, - }, '\u00D7'), - ) - }), + return React.createElement('button', { + type: 'button', + className: 'dsh-cc-edit-action', + title: '用古法编程编辑', + 'aria-label': '用古法编程编辑', + onClick: enter, + }, + React.createElement('svg', { + width: 14, + height: 14, + viewBox: '0 0 16 16', + 'aria-hidden': 'true', + fill: 'currentColor', + }, React.createElement('path', { + d: 'M12.146 1.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1 0 .708l-9.5 9.5a.5.5 0 0 1-.233.131l-3 .75a.5.5 0 0 1-.606-.606l.75-3a.5.5 0 0 1 .131-.233l9.5-9.5Z', + })), ) } -// ─── 编辑器面板组件 ──────────────────────────────────── - -/** 编辑器面板 props。 */ -interface EditorPanelProps { - onStatusChange: (status: string) => void -} - /** - * Monaco 编辑器面板:负责编辑器实例生命周期、文件打开与保存。 - * @param props - 组件 props。 + * 一个 code tab 的正文:读取文件、渲染 Monaco、保存回盘。 + * + * 每个 tab 各挂载一份实例,keepMounted 让已访问正文跨隐藏保留。 + * 地址解析失败或缺少 remote 时只渲染错误提示,不抛错崩溃。 + * @param props - 框架注入的 tab 信息钩子。 */ -function EditorPanel(props: EditorPanelProps): React.ReactElement { - const editorRef = React.useRef(null) - const editorInstanceRef = React.useRef(null) - const currentModelRef = React.useRef(null) - - // 每次渲染后检查编辑器容器是否就绪:挂载时渲染的是占位符, - // .dsh-cc-editor 容器要等第一个 Tab 打开才出现,因此不能只用 mount 一次的 effect。幂等:已创建则跳过。 - React.useEffect(function () { - if (editorRef.current && !editorInstanceRef.current) { - loadMonaco().then(function () { - if (editorRef.current && !editorInstanceRef.current) { - createEditor(editorRef.current) - } - }).catch(function (e: unknown) { - console.error('[classic-coding] Monaco 加载失败:', e) - }) - } - }) - - // 组件卸载时释放编辑器实例 - React.useEffect(function () { - // 主题动态跟随:DSH 切换明/暗时实时更新编辑器主题。 - // 回调读取全局 state._editorInstance,面板关闭重开也不会失效。 - if (!state._themeObserver) { - state._themeObserver = new MutationObserver(function () { - if (state._editorInstance) { - state._editorInstance.setTheme(isDarkTheme() ? 'vs-dark' : 'vs') - } - }) - state._themeObserver.observe(document.body, { - attributes: true, - attributeFilter: ['data-ds-dark-theme'], - }) - } - return function () { - if (editorInstanceRef.current) { - editorInstanceRef.current.dispose() - editorInstanceRef.current = null - } - if (state._editorInstance) state._editorInstance = null - } - }, []) - - // 最后一个标签关闭后,.dsh-cc-editor 容器会从 DOM 移除,改渲染占位符。 - // 若此层面板组件并未卸载,DSH overlay 保持挂载、仅靠 data-open 显隐, - // editorInstanceRef 会残留指向已分离容器的旧实例,再次打开时因实例非空而跳过重建, - // 导致「标签在、内容无」。故在无标签时主动销毁实例,让下次打开走 createEditor 重建 + 冲刷 pending,保证内容必达。 - const hasTabs = state.openTabs.length > 0 +function CodeTabBody(props: TabBodyProps): React.ReactElement { + const info = props.useTabInfo() + const address = info.tab.navigation.address + const signal = info.tab.signal + const parsed = parseFileAddress(address) + + const containerRef = React.useRef(null) + const editorRef = React.useRef(null) + const modelRef = React.useRef(null) + const themeObserverRef = React.useRef(null) + const saveRef = React.useRef<(() => void) | null>(null) + + const [content, setContent] = React.useState(null) + const [absolutePath, setAbsolutePath] = React.useState(null) + const [phase, setPhase] = React.useState<'loading' | 'ready' | 'error'>('loading') + const [message, setMessage] = React.useState('') + const [dirty, setDirty] = React.useState(false) + + // 读取内容:地址变化即重读。取消标记避免旧请求覆盖新 tab 的正文。 React.useEffect(function () { - if (!hasTabs && editorInstanceRef.current) { - editorInstanceRef.current.dispose() - editorInstanceRef.current = null - if (state._editorInstance) state._editorInstance = null - if (currentModelRef.current) { - currentModelRef.current.dispose() - currentModelRef.current = null - } - } - }, [hasTabs]) - - /** - * 创建 Monaco 实例并接线 Ctrl+S、内容变更与挂起文件。 - * @param container - 编辑器容器元素。 - */ - function createEditor(container: HTMLDivElement): void { - editorInstanceRef.current = window.monaco.editor.create(container, { - value: '// 欢迎使用古法编程!\n// 从左侧文件树打开文件开始编辑\n// Ctrl+S 保存文件\n', - language: 'plaintext', - theme: isDarkTheme() ? 'vs-dark' : 'vs', - fontSize: 14, - fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", - minimap: { enabled: true }, - scrollBeyondLastLine: false, - automaticLayout: true, - tabSize: 2, - wordWrap: 'on', - lineNumbers: 'on', - }) - // 同步到全局 state:主题 observer 与状态栏等跨组件读取 - state._editorInstance = editorInstanceRef.current - - // Ctrl+S 保存 - editorInstanceRef.current.addCommand( - window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyS, - function () { - if (state.activeTab) saveFile(state.activeTab) - }, - ) - - // 若已有打开的文件,Monaco 晚于 openFile 加载完成时直接显示当前 Tab - if (state.activeTab) { - const current = state.openTabs.find(function (t) { return t.path === state.activeTab }) - if (current) { - currentModelRef.current = window.monaco.editor.createModel(current.content, langByPath(state.activeTab)) - editorInstanceRef.current.setModel(currentModelRef.current) - } - } - - // 冲刷挂起的待显示路径,尾巴点击时 Monaco 未就绪挂起的文件 - if (state._pendingShowPath) { - const pending = state._pendingShowPath - state._pendingShowPath = null - showFileInEditor(pending) - } - - // 监听内容变化 - editorInstanceRef.current.onDidChangeModelContent(function () { - if (!state.activeTab) return - const tab = state.openTabs.find(function (t) { return t.path === state.activeTab }) - if (tab && editorInstanceRef.current) { - tab.content = editorInstanceRef.current.getValue() - tab.modified = true - refresh() - } - }) - } - - /** - * 打开文件:已打开则切标签,否则读盘后新建标签。 - * @param filePath - 文件树内相对路径。 - */ - function openFile(filePath: string): void { - const existing = state.openTabs.find(function (t) { return t.path === filePath }) - if (existing) { - state.activeTab = filePath - showFileInEditor(filePath) - refresh() + if (!parsed || !remote) { + setPhase('error') + setMessage(parsed ? '缺少 remote 服务,无法读取工作区文件' : '无法识别的文件地址') return } - - rpcCall('readFile', { path: toAbs(filePath) }).then(function (result) { - state.openTabs.push({ path: filePath, content: result.content, modified: false }) - state.activeTab = filePath - showFileInEditor(filePath) - refresh() + let cancelled = false + setPhase('loading') + setMessage('') + remote.workspaceFiles.readBytes(parsed.sessionId, parsed.path, {}, signal).then(function (result) { + if (cancelled) return + if (!result.ok) { + setPhase('error') + setMessage('读取失败: ' + (result.error && result.error.message ? result.error.message : String(result.error))) + return + } + const file = result.value + setAbsolutePath(file.absolutePath) + setContent(new TextDecoder().decode(file.data)) + setPhase('ready') }).catch(function (e: unknown) { - console.error('[classic-coding] 读取文件失败:', filePath, e) - refresh() + if (cancelled) return + setPhase('error') + setMessage(e instanceof Error ? e.message : String(e)) }) - } - - /** - * 保存文件:写盘后清修改标记。 - * @param filePath - 文件树内相对路径。 - */ - function saveFile(filePath: string): void { - const tab = state.openTabs.find(function (t) { return t.path === filePath }) - if (!tab) return + return function () { cancelled = true } + }, [address]) - if (editorInstanceRef.current && state.activeTab === filePath) { - tab.content = editorInstanceRef.current.getValue() - } - - rpcCall('writeFile', { path: toAbs(filePath), content: tab.content }).then(function () { - tab.modified = false - props.onStatusChange('\u2705 已保存: ' + filePath) - refresh() + // 内容就绪后创建编辑器与模型;内容更新则重建模型,不重复建编辑器。 + React.useEffect(function () { + const container = containerRef.current + if (!container || content === null) return + let disposed = false + loadMonaco().then(function () { + if (disposed) return + if (!editorRef.current) { + const editor = window.monaco.editor.create(container, { + language: 'plaintext', + theme: isDarkTheme() ? 'vs-dark' : 'vs', + fontSize: 13, + fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + wordWrap: 'on', + lineNumbers: 'on', + }) + editorRef.current = editor + editor.addCommand( + window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyS, + function () { if (saveRef.current) saveRef.current() }, + ) + editor.onDidChangeModelContent(function () { setDirty(true) }) + const observer = new MutationObserver(function () { + if (editorRef.current) editorRef.current.setTheme(isDarkTheme() ? 'vs-dark' : 'vs') + }) + observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] }) + themeObserverRef.current = observer + } + if (modelRef.current) modelRef.current.dispose() + modelRef.current = window.monaco.editor.createModel(content, langByPath(parsed ? parsed.path : '')) + editorRef.current.setModel(modelRef.current) }).catch(function (e: unknown) { - console.error('[classic-coding] 保存失败:', filePath, e) - props.onStatusChange('\u274C 保存失败: ' + filePath) + if (disposed) return + setPhase('error') + setMessage(e instanceof Error ? e.message : String(e)) }) - } - - /** - * 在编辑器中显示文件,Monaco 未就绪时挂起待显示。 - * @param filePath - 文件树内相对路径。 - */ - function showFileInEditor(filePath: string): void { - // Monaco 尚未就绪时挂起待显示,编辑器创建完成后统一冲刷,保证内容必达 - if (!editorInstanceRef.current) { - state._pendingShowPath = filePath - return - } - const tab = state.openTabs.find(function (t) { return t.path === filePath }) - if (!tab) return - - if (currentModelRef.current) currentModelRef.current.dispose() - currentModelRef.current = window.monaco.editor.createModel(tab.content, langByPath(filePath)) - editorInstanceRef.current.setModel(currentModelRef.current) - props.onStatusChange(filePath) - refresh() - } + return function () { disposed = true } + }, [content]) - // 暴露方法给父组件:经全局 state 桥接,forwardRef 不可用 + // tab 记录消失即卸载:断开主题观察、释放编辑器与模型。 React.useEffect(function () { - state._openFile = openFile - state._saveFile = saveFile return function () { - state._openFile = null - state._saveFile = null - } - }) - - return React.createElement('div', { className: 'dsh-cc-editor-panel' }, - hasTabs - ? React.createElement('div', { ref: editorRef, className: 'dsh-cc-editor' }) - : React.createElement('div', { className: 'dsh-cc-editor-placeholder' }, '从左侧文件树打开文件开始编辑'), - ) -} - -// ─── 状态栏组件 ──────────────────────────────────────── - -/** 状态栏 props。 */ -interface StatusBarProps { - status: string -} - -/** - * 底部状态栏:显示当前文件路径或保存结果。 - * @param props - 组件 props。 - */ -function StatusBar(props: StatusBarProps): React.ReactElement { - return React.createElement('div', { className: 'dsh-cc-status' }, props.status) -} - -// ─── 主面板组件 ──────────────────────────────────────── - -/** 右侧覆盖层面板:文件树 + 标签栏 + 编辑器 + 状态栏。 */ -function ClassicCodingPanel(): React.ReactElement { - const [status, setStatus] = React.useState('就绪') - const [isOpen, setIsOpen] = React.useState(state.isOpen) - const [, forceUpdate] = React.useState(0) - - // 同步 state.isOpen 和 React 状态 - React.useEffect(function () { - state._setIsOpen = setIsOpen - state._refresh = function () { forceUpdate(function (n) { return n + 1 }) } - return function () { - state._setIsOpen = null - state._refresh = null + if (themeObserverRef.current) { + themeObserverRef.current.disconnect() + themeObserverRef.current = null + } + if (editorRef.current) { + editorRef.current.dispose() + editorRef.current = null + } + if (modelRef.current) { + modelRef.current.dispose() + modelRef.current = null + } } }, []) - function handleFileOpen(filePath: string): void { - if (state._openFile) state._openFile(filePath) - } - - function handleTabSelect(path: string): void { - state.activeTab = path - if (state._openFile) state._openFile(path) - } - - function handleTabClose(path: string): void { - state.openTabs = state.openTabs.filter(function (t) { return t.path !== path }) - if (state.activeTab === path) { - const last = state.openTabs[state.openTabs.length - 1] - state.activeTab = last ? last.path : null - if (state.activeTab) handleTabSelect(state.activeTab) - } - refresh() + /** 取编辑器当前内容写回绝对路径。 */ + function save(): void { + if (!editorRef.current || !absolutePath) return + setMessage('保存中...') + rpcCall('writeFile', { path: absolutePath, content: editorRef.current.getValue() }) + .then(function () { + setDirty(false) + setMessage('已保存') + }) + .catch(function (e: unknown) { + setMessage('保存失败: ' + (e instanceof Error ? e.message : String(e))) + }) } - function handleClose(): void { - state.isOpen = false - setIsOpen(false) + /** 切回内置查看器:不带 kind 时由注册表认领,code 类型不参与自动认领。 */ + function back(): void { + if (!sidebarRight) return + // 用自身 id 作 replaceTab:新 tab 落在同格同格位,本 tab 随即关闭。 + sidebarRight.openResource(address, { replaceTab: info.tab.id }) } - // Esc 关闭 + // Ctrl+S 命令只绑定一次,经 ref 拿到最新 save,避免闭包捕获旧 absolutePath。 React.useEffect(function () { - function onKeyDown(e: KeyboardEvent): void { - if (e.key === 'Escape' && state.isOpen) { - handleClose() - } - } - document.addEventListener('keydown', onKeyDown) - return function () { document.removeEventListener('keydown', onKeyDown) } - }, []) - - return React.createElement('div', attrs({ - className: 'dsh-cc-overlay', - 'data-open': isOpen ? 'true' : 'false', - }), - // 半透明遮罩:点击关闭面板 - React.createElement('div', { - className: 'dsh-cc-backdrop', - onClick: handleClose, - }), - // 右侧滑入面板 - React.createElement('div', { className: 'dsh-cc-panel' }, - React.createElement('div', { className: 'dsh-cc-header' }, - React.createElement('span', { className: 'dsh-cc-header-title' }, - '\u2328\uFE0F 古法编程', - ), - React.createElement('button', { - className: 'dsh-cc-close', - onClick: handleClose, - title: '关闭 Esc', - }, '\u00D7'), - ), - React.createElement(TabBar, { - onTabSelect: handleTabSelect, - onTabClose: handleTabClose, - }), - React.createElement('div', { className: 'dsh-cc-body' }, - React.createElement('div', { className: 'dsh-cc-tree-panel' }, - React.createElement(FileTree, { onFileOpen: handleFileOpen }), - ), - React.createElement(EditorPanel, { - onStatusChange: setStatus, - }), - ), - React.createElement(StatusBar, { status: status }), - ), - ) -} - -// ─── 侧边栏触发按钮 ──────────────────────────────────── - -/** 侧边栏底部触发按钮。 */ -function TriggerButton(): React.ReactElement { - function toggle(): void { - const next = !state.isOpen - state.isOpen = next - if (state._setIsOpen) state._setIsOpen(next) - } + saveRef.current = save + }) - return React.createElement('button', attrs({ - className: 'dsh-cc-trigger', - title: '古法编程 Classic Coding', - type: 'button', - 'aria-label': '古法编程 Classic Coding', - onClick: toggle, - }), - React.createElement( - 'div', - { className: 'dsh-cc-trigger-head' }, - React.createElement('span', attrs({ className: 'dsh-cc-trigger-icon', 'aria-hidden': 'true' }), '\u2328'), - React.createElement('span', { className: 'dsh-cc-trigger-title' }, '古法编程'), + const label = parsed ? basename(parsed.path) : address + return React.createElement('div', { className: 'dsh-cc-body' }, + React.createElement('div', { className: 'dsh-cc-toolbar' }, + React.createElement('span', { className: 'dsh-cc-filename' }, dirty ? label + ' ●' : label), + React.createElement('button', { + type: 'button', + className: 'dsh-cc-save', + onClick: save, + disabled: phase !== 'ready' || !dirty, + title: '保存 Ctrl+S', + }, '保存'), + React.createElement('button', { + type: 'button', + className: 'dsh-cc-save', + onClick: back, + title: '返回查看', + }, '查看'), + React.createElement('span', { className: 'dsh-cc-status' }, message), ), - React.createElement('div', { className: 'dsh-cc-trigger-desc' }, '打开文件编辑器'), + React.createElement('div', { ref: containerRef, className: 'dsh-cc-editor' }), + phase === 'loading' ? React.createElement('div', { className: 'dsh-cc-message' }, '加载中...') : null, + phase === 'error' ? React.createElement('div', { className: 'dsh-cc-message' }, '打开失败: ' + message) : null, ) } -// ─── 消息流 fileLink 尾巴注入 ────────────────────────── - -/** - * DSH 对话消息流中需要尾巴注入的两种文件按钮: - * 1. 工具行 fileLink,CSS module hash 类名以 fileLink 结尾如 o3BgMG_fileLink, - * 按钮文本是相对会话 cwd 的路径,显示层已 relativizeToCwd,点击默认由 Host 用系统默认应用打开。 - * 2. 产物区文件按钮,data-produced-files-row 容器内的 button,文本是 basename,完整绝对路径在 title 属性里。 - * 统一规则:在按钮后追加「古法编程」尾巴——点击路径本身保持原行为,外部打开, - * 点击「古法编程」四字则在插件内打开该文件。 - */ -const LINK_BTN_SELECTOR = 'button[class*="fileLink"], [data-produced-files-row="true"] button[title]' - -/** - * 从按钮提取目标路径:优先用注入时缓存的干净路径即 data-cc-path, - * 其次产物区按钮的 title 即绝对路径,否则用文本即相对路径。 - * 不直接读 textContent——尾巴 span 注入后 textContent 会带上「古法编程」, - * 重扫时若据此覆盖 dataset.path 会污染路径。 - * @param btn - 文件按钮元素。 - */ -function btnPath(btn: Element): string { - const cached = btn.getAttribute('data-cc-path') - if (cached) return cached - const title = btn.getAttribute('title') - if (title && /^[A-Za-z]:[\\/]|^\\\\/.test(title)) return title - return (btn.textContent || '').trim() -} - -/** - * 为指定容器内的所有 fileLink 按钮注入尾巴 span。幂等:已注入且路径未变则跳过。 - * 注入时把路径快照存到 span.dataset.path 供点击用,并把干净路径缓存到 - * 按钮 data-cc-path,重扫时从此读,避免尾巴文本污染 textContent 后误覆盖。 - * @param root - 待扫描的容器。 - */ -function injectLinkTails(root: ParentNode): void { - if (!root || !root.querySelectorAll) return - const buttons = root.querySelectorAll(LINK_BTN_SELECTOR) - for (let i = 0; i < buttons.length; i++) { - const btn = buttons[i] - if (!btn) continue - const path = btnPath(btn) - // 把干净路径缓存到按钮上,避免后续尾巴文本污染 textContent 后误覆盖 dataset.path - if (!btn.hasAttribute('data-cc-path')) btn.setAttribute('data-cc-path', path) - if (!path) continue - const existing = btn.querySelector('.dsh-cc-open-here') - if (existing) { - // 重渲染后按钮文本可能更新,同步路径快照,优先取缓存的干净路径 - if (existing.dataset.path !== path) existing.dataset.path = path - continue - } - const span = document.createElement('span') - span.className = 'dsh-cc-open-here' - span.textContent = '古法编程' - span.title = '在古法编程中打开' - span.dataset.path = path - btn.appendChild(document.createTextNode('\u00A0')) - btn.appendChild(span) - } -} - -/** - * 在插件内打开消息流 fileLink 指出的文件:打开面板 → 等 root 就绪 → 读文件。 - * 路径可能相对当前会话 cwd,DSH 显示层已 relativizeToCwd,也可能本身就是绝对路径。 - * @param rawPath - 尾巴携带的路径快照。 - */ -function openPathInPicker(rawPath: string): void { - state.isOpen = true - if (state._setIsOpen) state._setIsOpen(true) - - // 面板首次打开时 root 尚未加载,轮询等待就绪 - let attempts = 0 - function tryOpen(): void { - if (!state.root || !state._openFile) { - if (attempts++ < 40) { setTimeout(tryOpen, 250); return } - console.error('[classic-coding] 文件树根未就绪,无法在插件内打开:', rawPath) - return - } - if (state._openFile) state._openFile(rawPath) - } - tryOpen() -} - -/** - * 捕获阶段拦截点击:点击「古法编程」尾巴时阻止默认外部打开,改为插件内打开。 - * @param e - 点击事件。 - */ -function onDocClickCapture(e: MouseEvent): void { - const target = e.target as Element | null - const pill = target && target.closest ? target.closest('.dsh-cc-open-here') : null - if (!pill) return - const rawPath = pill.dataset.path - if (!rawPath) return - e.preventDefault() - e.stopPropagation() - if (e.stopImmediatePropagation) e.stopImmediatePropagation() - openPathInPicker(rawPath) -} - -/** 启动消息流尾巴注入:MutationObserver 监听 DOM 变化 + 文档级点击拦截。 */ -function startLinkTailWatcher(): void { - if (state._linkTailStarted) return - state._linkTailStarted = true - injectLinkTails(document) - state._linkTailObserver = new MutationObserver(function () { - // React 更新按钮文本时可能用 textContent 整体覆盖,清掉尾巴且只产生 - // Text 节点变更,因此每次变更全文档重扫,幂等保证不重复注入。 - injectLinkTails(document) - }) - state._linkTailObserver.observe(document.body, { childList: true, subtree: true, characterData: true }) - document.addEventListener('click', onDocClickCapture, true) -} - -/** 停止尾巴注入:断开 observer、移除注入的 span、移除点击拦截。 */ -function stopLinkTailWatcher(): void { - if (state._linkTailObserver) { - state._linkTailObserver.disconnect() - state._linkTailObserver = null - } - document.removeEventListener('click', onDocClickCapture, true) - state._linkTailStarted = false - // 移除已注入的尾巴,恢复按钮原始外观 - const pills = document.querySelectorAll('.dsh-cc-open-here') - for (let i = 0; i < pills.length; i++) { - const pill = pills[i] - if (!pill) continue - const prev = pill.previousSibling - if (prev && prev.nodeType === 3 && /^\u00A0+$/.test(prev.textContent || '')) { - if (prev.parentNode) prev.parentNode.removeChild(prev) - } - if (pill.parentNode) pill.parentNode.removeChild(pill) - } -} - // ─── 插件入口 ────────────────────────────────────────── /** 插件名,与 cordis.patch.yml 的 name 一致。 */ export const name = 'dsh-classic-coding' -/** 依赖的客户端服务:slots 与 sessions 都由客户端核心插件提供。 */ -export const inject: string[] = ['slots', 'sessions'] +/** 依赖的客户端服务:右侧栏 tab 注册表、槽位、远端命名空间及其 workspaceFiles 面。 */ +export const inject: string[] = ['slots', 'sidebarRightTabs', 'sidebarRight', 'remote', 'remote.workspaceFiles'] /** - * 客户端 apply:注册侧边栏按钮与覆盖层面板,并启动样式与尾巴注入。 + * 客户端 apply:注册 code tab 类型与它的正文。 + * + * 类型以 extension 优先级压过内置 text 的 fallback,但只认领代码扩展名与会话地址, + * 其余文件仍由内置查看器处理。注册与插件同寿,故放进 ctx.effect。 * @param ctx - 客户端插件上下文。 */ export function apply(ctx: ClientContext): void { const slots = ctx.get('slots') as SlotsService | undefined if (!slots) return - const sessionsService = ctx.get('sessions') as SessionsService | undefined - if (!sessionsService) throw new Error('[classic-coding] 缺少 sessions 服务,请确认 dsh-client-runtime 已加载') - sessions = sessionsService + const tabs = ctx.get('sidebarRightTabs') as TabsService | undefined + if (!tabs) throw new Error('[classic-coding] 缺少 sidebarRightTabs 服务,请确认 dsh-client-ui-sidebar-right 已加载') + const remoteService = ctx.get('remote') as RemoteService | undefined + if (!remoteService) throw new Error('[classic-coding] 缺少 remote 服务,请确认 dsh-api-workspace-files 已加载') + const right = ctx.get('sidebarRight') as SidebarRightService | undefined + if (!right) throw new Error('[classic-coding] 缺少 sidebarRight 服务,请确认 dsh-client-ui-sidebar-right 已加载') + remote = remoteService + sidebarRight = right - // 注入样式 + 消息流尾巴,effect 管理生命周期 ctx.effect(function () { ensureStyle() - startLinkTailWatcher() - return function () { - removeStyle() - stopLinkTailWatcher() - } + return function () { removeStyle() } }) - // 注册侧边栏按钮 - slots.inject('sidebar.footer.action', function () { - return slots.register( - { name: 'sidebar.footer.action', id: 'dsh-classic-coding-trigger', order: 15 }, - function () { return React.createElement(TriggerButton) }, - ) + ctx.effect(function () { + return tabs.register({ + id: TYPE_ID, + kind: KIND, + priority: 'extension', + canOpen: function (address) { return address.startsWith(SESSION_FILE_PREFIX) }, + title: function (address) { + const target = parseFileAddress(address) + return target ? basename(target.path) : address + }, + keepMounted: true, + }) }) - // 注册覆盖层面板 - slots.inject('shell.overlay', function () { + slots.inject('sidebar.right.pane.tab', function () { + return slots.register({ name: 'sidebar.right.pane.tab', key: TYPE_ID }, CodeTabBody) + }) + + slots.inject('sidebar.right.tab.document.actions', function () { return slots.register( - { name: 'shell.overlay', id: 'dsh-classic-coding-panel' }, - function () { return React.createElement(ClassicCodingPanel) }, + { name: 'sidebar.right.tab.document.actions', id: 'dsh-classic-coding-edit', order: 20 }, + EditAction, ) }) } const plugin = { name, inject, apply } - export default plugin + diff --git a/src/index.ts b/src/index.ts index 8586eb6..92a0d14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ /** * 古法编程 - Host 端 * - * 注册 RPC 端点,为 Client 端提供文件系统操作能力。 - * 经 mountRpcChannel 直接向 webServer 自注册 /classic-coding 前缀路由,复用 connection 的信任判定, + * 只注册一个保存端点。文件内容与目录列举交给 DSH 内置 workspaceFiles 服务, + * 该服务官方不提供修改操作,故写入保留自注册的 /classic-coding writeFile。 + * + * 经 mountRpcChannel 直接向 webServer 自注册前缀路由,复用 connection 的信任判定, * 实现官方 client-request/server-response 信封。共享通道 /api 的唯一拦截器槽位已被官方 gateway 占用。 * * 官方 @deepseek-ai 服务面在此以最小结构接口描述,插件运行时由 host 注入真实实现, @@ -11,72 +13,20 @@ * @module dsh-classic-coding */ -import { readdir, writeFile } from 'node:fs/promises' +import { writeFile } from 'node:fs/promises' import { isAbsolute } from 'node:path' import { mountRpcChannel, type RpcChannelResult } from './rpc-channel.ts' -/** 文件系统服务:仅用到解析、路径转换与读文本三个方法。 */ -interface FsService { - /** 把路径解析成后端路径对象,受 signal 取消。 */ - resolve(path: string, options?: { signal?: AbortSignal }): Promise - /** 把后端路径对象转成本机进程可用的字符串路径。 */ - processPath(target: unknown): string - /** 读取文本文件。 */ - readText(target: unknown): Promise -} - -/** 会话记录的最小面:只需要 header.cwd。 */ -interface SessionRecord { - header?: { cwd?: string } -} - -/** 会话服务:按 id 取内存中的 live 会话。 */ -interface SessionsService { - get(sessionId: string): SessionRecord | undefined -} - -/** 会话持久化:按 id 冷读存储快照,只需要 header.cwd。 */ -interface SessionPersistenceService { - stat(sessionId: string, options?: { signal?: AbortSignal }): Promise<{ header?: { cwd?: string } } | undefined> -} - -/** 插件上下文:apply 与 inject 回调共同可见的服务面。 */ +/** 插件上下文:connection 与 webServer 由 mountRpcChannel 延迟注入。 */ interface PluginContext { - fs: FsService - inject( - dependencies: string[], - callback: (scope: PluginContext & { - sessions: SessionsService - sessionPersistence: SessionPersistenceService - }) => void, - ): void + inject(dependencies: string[], callback: (scope: unknown) => void): unknown } /** 插件名,即 cordis.yml 配置条目 id */ const name = 'dsh-classic-coding' -/** 依赖的服务:fs 即文件系统;sessions、sessionPersistence 在 apply 内显式等待 */ -const inject = ['fs'] - -/** 需要隐藏的目录 */ -const HIDDEN_DIRS = new Set([ - '.git', 'node_modules', '.next', '.cache', - '__pycache__', '.venv', 'dist', 'build', '.dsh', -]) - -/** - * 强制要求绝对路径:防止相对路径被 fs.resolve 按进程 cwd 误解析。 - * 文件树根由当前会话的 cwd 决定,不依赖 dsh 进程启动目录。 - * @param path - 待校验路径。 - */ -function requireAbsolute(path: unknown): asserts path is string { - if (typeof path !== 'string' || path.length === 0) { - throw new Error('path 必须是非空字符串') - } - if (!isAbsolute(path)) { - throw new Error(`path 必须是绝对路径:收到 ${path}`) - } -} +/** 依赖的服务:仅 connection 与 webServer,且延迟到挂载通道时注入。 */ +const inject: string[] = [] /** 把未知 payload 收窄成字符串字段表。 */ function asRecord(value: unknown): Record { @@ -90,130 +40,17 @@ function readString(record: Record, key: string): string | unde } /** - * Host 端 apply:注册文件系统 RPC 端点。 - * - * 用 ctx.inject(['sessions', 'sessionPersistence'], ...) 延迟获取服务, - * 再经 mountRpcChannel 自注册独立的 /classic-coding 通道。 - * dsh 0.1.5 的 connection.rpc.handle 在登记路由时解析 webServer 会抛 without inject,因此自行注册 prefix 路由。 - * @param ctx - 插件上下文。 - */ -function apply(ctx: PluginContext): void { - ctx.inject(['sessions', 'sessionPersistence'], function (scope) { - mountRpcChannel(scope, '/classic-coding', async function (endpoint, payload, signal): Promise { - // 独立通道下 endpoint 即方法名,不含通道前缀 - const method = endpoint - try { - switch (method) { - case 'describe': - return { ok: true, value: await handleDescribe(scope.sessions, scope.sessionPersistence, payload, signal) } - case 'listDir': - return { ok: true, value: await handleListDir(ctx, payload, signal) } - case 'readFile': - return { ok: true, value: await handleReadFile(ctx, payload) } - case 'writeFile': - return { ok: true, value: await handleWriteFile(payload) } - default: - return { - ok: false, - error: { code: 'bad-request', message: `未知端点: ${method}`, details: { issues: [] } }, - } - } - } catch (e) { - return { - ok: false, - error: { - code: 'internal', - message: e instanceof Error ? e.message : String(e), - details: {}, - }, - } - } - }) - }) -} - -/** - * 处理 describe:返回当前会话工作区的绝对路径作为文件树根。 - * 会话 id 由客户端从注入的 sessions 服务读取 sessions.list.current,与侧栏选择同步。 - * - * 会话已挂载为 live 即内存 SessionStore 命中时直接取 header.cwd; - * 否则,如浏览器刷新、会话尚未被 host resume 进 live 表时回退到 - * sessionPersistence.stat 从磁盘冷读快照 header.cwd。两者都拿不到才抛错, - * 绝不静默回退到进程目录。 - * @param sessions - 会话服务。 - * @param sessionPersistence - 会话持久化服务。 - * @param payload - RPC 载荷,需带 sessionId。 - * @param signal - 取消信号。 + * 强制要求绝对路径:防止相对路径按 dsh 进程启动目录误写。 + * 客户端传入的绝对路径来自内置 workspaceFiles 的 stat.absolutePath,与工作区根一致。 + * @param path - 待校验路径。 */ -async function handleDescribe( - sessions: SessionsService, - sessionPersistence: SessionPersistenceService, - payload: unknown, - signal: AbortSignal, -): Promise<{ root: string }> { - const sessionId = readString(asRecord(payload), 'sessionId') - if (!sessionId) throw new Error('describe 缺少 sessionId') - - // 快路径:会话已在 host 内存 live 表中 - const live = sessions.get(sessionId) - const liveCwd = live?.header?.cwd - if (liveCwd) { - return { root: liveCwd } +function requireAbsolute(path: unknown): asserts path is string { + if (typeof path !== 'string' || path.length === 0) { + throw new Error('path 必须是非空字符串') + } + if (!isAbsolute(path)) { + throw new Error(`path 必须是绝对路径:收到 ${path}`) } - - // 兜底:从持久化冷读会话 header,覆盖刷新后尚未 resume 的会话 - const snapshot = await sessionPersistence.stat(sessionId, { signal }) - const coldCwd = snapshot?.header?.cwd - if (coldCwd) return { root: coldCwd } - if (live) throw new Error(`会话 ${sessionId} 工作目录缺失:header.cwd 为空`) - throw new Error(`会话不存在: ${sessionId}`) -} - -/** - * 处理 listDir:列出目录内容。 - * @param ctx - 插件上下文。 - * @param payload - RPC 载荷,需带绝对 path。 - * @param signal - 取消信号。 - */ -async function handleListDir( - ctx: PluginContext, - payload: unknown, - signal: AbortSignal, -): Promise<{ entries: { name: string; type: 'directory' | 'file'; size: number }[] }> { - const path = readString(asRecord(payload), 'path') - requireAbsolute(path) - const target = await ctx.fs.resolve(path, { signal }) - const dirPath = ctx.fs.processPath(target) - const items = await readdir(dirPath, { withFileTypes: true }) - - const entries = items - .filter(function (item) { return !HIDDEN_DIRS.has(item.name) && !item.name.startsWith('.') }) - .map(function (item) { - return { - name: item.name, - type: item.isDirectory() ? 'directory' as const : 'file' as const, - size: 0, - } - }) - .sort(function (a, b) { - if (a.type === b.type) return a.name.localeCompare(b.name) - return a.type === 'directory' ? -1 : 1 - }) - - return { entries } -} - -/** - * 处理 readFile:读取文件内容。 - * @param ctx - 插件上下文。 - * @param payload - RPC 载荷,需带绝对 path。 - */ -async function handleReadFile(ctx: PluginContext, payload: unknown): Promise<{ content: string }> { - const path = readString(asRecord(payload), 'path') - requireAbsolute(path) - const target = await ctx.fs.resolve(path) - const content = await ctx.fs.readText(target) - return { content } } /** @@ -236,4 +73,34 @@ async function handleWriteFile(payload: unknown): Promise<{ ok: true }> { return { ok: true } } +/** + * Host 端 apply:注册保存端点。 + * @param ctx - 插件上下文。 + */ +function apply(ctx: PluginContext): void { + mountRpcChannel(ctx, '/classic-coding', async function (endpoint, payload): Promise { + try { + switch (endpoint) { + case 'writeFile': + return { ok: true, value: await handleWriteFile(payload) } + default: + return { + ok: false, + error: { code: 'bad-request', message: `未知端点: ${endpoint}`, details: { issues: [] } }, + } + } + } catch (e) { + return { + ok: false, + error: { + code: 'internal', + message: e instanceof Error ? e.message : String(e), + details: {}, + }, + } + } + }) +} + export { apply, inject, name } +