Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
45 changes: 12 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,22 @@

为了在 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 秒 |
| :---: |
| [![古法编程插件演示](https://i2.hdslb.com/bfs/archive/0d8bde731b526538d69ec983883a4bf755d6cf81.jpg)](https://www.bilibili.com/video/BV1d28i6rEEz/) |

> 视频录制于旧版覆盖层面板时期,交互入口已改为 dsh 内置右侧栏 tab。

## 安装

**从 GitHub 安装**:源码在 `src/`,`lib/` 不入仓库,安装时 npm 会触发 `prepare` 脚本现场构建。
Expand Down Expand Up @@ -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)
[MIT](./LICENSE)
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
124 changes: 55 additions & 69 deletions smoke/verify.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -122,23 +86,28 @@ 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)

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()

Expand All @@ -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() {},
Expand All @@ -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,
}
Expand All @@ -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)
process.exit(failed === 0 ? 0 : 1)
Loading
Loading