From ded624254840d6a1db605f593244337fa10fefad Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 23:39:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E5=9C=B0=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=BA=A7=E6=89=98=E7=AE=A1(/api/worktable/si?= =?UTF-8?q?te=E5=89=8D=E7=BC=80=E8=B7=AF=E7=94=B1)=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E7=82=B9html=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E7=9B=B8=E5=AF=B9=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/src/client/split.tsx | 11 +++- 01_content/src/index.ts | 51 ++++++++++++++++++- 02_process/PRD.md | 2 +- .../worklogs/2026-08-16_worktable-v2.md | 37 +++++--------- 04_test/fixture-site/assets/app.js | 1 + 04_test/fixture-site/assets/style.css | 1 + 04_test/fixture-site/index.html | 12 +++++ 04_test/functional-diag.cjs | 33 ++++++++++++ 8 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 04_test/fixture-site/assets/app.js create mode 100644 04_test/fixture-site/assets/style.css create mode 100644 04_test/fixture-site/index.html diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 96117f8..9e94270 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -16,7 +16,7 @@ import MarkdownIt from 'markdown-it' export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal' export type SplitContent = - | { kind: 'iframe'; url: string } + | { kind: 'iframe'; url: string; title?: string } | { kind: 'builtin'; type: BuiltinType } | { kind: 'file'; path: string } @@ -128,6 +128,7 @@ const BUILTIN_LABEL_KEYS: Record = { function tabTitleOf(content: SplitContent): string { if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type]) if (content.kind === 'file') return basenameOf(content.path) + if (content.kind === 'iframe' && content.title) return content.title try { const u = new URL(content.url) return u.hostname || content.url @@ -929,7 +930,13 @@ function ExplorerPane(props: { row: PaneRow; index: number }) { onClick={() => { if (e.isDir) { toggle(e.path); return } if (/\.html?$/i.test(e.name)) { - splitStore.openTab(props.row, props.index, { kind: 'iframe', url: '/api/worktable/file?path=' + encodeURIComponent(e.path) }) + // 目录级静态托管:相对引用(./assets/...)在所在目录下解析,页面可完整渲染 + const dir = parentPathOf(e.path) + splitStore.openTab(props.row, props.index, { + kind: 'iframe', + url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(e.name), + title: e.name, + }) } else if (/\.(md|markdown|mdown|txt|log|pdf|png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(e.name)) { splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path }) } else { diff --git a/01_content/src/index.ts b/01_content/src/index.ts index e7e82ec..3ca1595 100644 --- a/01_content/src/index.ts +++ b/01_content/src/index.ts @@ -23,6 +23,21 @@ export const HEALTH_PATH = '/api/worktable/health' const MAX_ENTRIES = 500 +/** 本地文件/站点静态资源的 MIME 映射(file 与 site 两条路由共用) */ +const FILE_TYPES: Record = { + html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8', + md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', + txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8', + pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', + gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif', + woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', + wasm: 'application/wasm', mp3: 'audio/mpeg', mp4: 'video/mp4', webm: 'video/webm', +} + +const SITE_PREFIX = '/api/worktable/site' + /** * 从本插件模块位置向祖先方向查找并加载 node_modules 包(如 ws / node-pty)。 * 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖; @@ -211,7 +226,41 @@ export function apply(ctx: Context) { json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8', pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', } - res.writeHead(200, { 'content-type': types[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' }) + res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' }) + res.end(data) + } catch (err) { + json(res, 404, { error: String(err) }) + } + }, + }) + + // 本地站点(目录级静态托管):点开 index.html 时挂载整个所在目录, + // 让 ./assets/... 等相对引用正常解析(前缀路由,余下路径 = /<相对路径>)。 + webServer.register({ + kind: 'prefix', + path: SITE_PREFIX, + handler: async (req: any, res: any) => { + try { + if (req.method !== 'GET') { res.writeHead(405); res.end(); return } + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + const segs = pathname.slice(SITE_PREFIX.length).split('/').filter(Boolean) + const rootToken = segs.shift() ?? '' + const rel = segs.map((s) => { try { return decodeURIComponent(s) } catch { return s } }).join('/') + if (!rootToken) { json(res, 400, { error: 'missing root' }); return } + const root = pathResolve(rootToken) + let abs = pathResolve(root, rel) + if (abs !== root && !abs.startsWith(root + sep)) { json(res, 403, { error: 'outside root' }); return } + const statMod = await import('node:fs/promises') + let info = await statMod.stat(abs).catch(() => null) + if (info && info.isDirectory()) { + abs = pathResolve(abs, 'index.html') + info = await statMod.stat(abs).catch(() => null) + } + if (!info || !info.isFile()) { json(res, 404, { error: 'not found' }); return } + if (info.size > 40 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return } + const data = await readFile(abs) + const ext = (abs.split('.').pop() || '').toLowerCase() + res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' }) res.end(data) } catch (err) { json(res, 404, { error: String(err) }) diff --git a/02_process/PRD.md b/02_process/PRD.md index 5d533d7..3290fa5 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -353,7 +353,7 @@ type SplitPane = { | 内容窗 | 可行性 | 依据 | | --- | --- | --- | | 浏览器 | ✅ 已实现 | iframe + 地址栏 | -| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html iframe、.md markdown-it 渲染、.txt/.log 纯文本、.pdf pdf.js 自绘阅读器(抓手:按住空格临时拖动 / H 切换常开 / 顶部 ✋ 按钮,缩放与适应宽度)、常见图片居中展示;pdf.js worker 源码注入客户端 bundle(Blob URL 起 module worker,零服务端依赖) | +| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html → /api/worktable/site 目录级静态托管(相对资源随目录解析,本地网页完整渲染;PDF 已回退原生 iframe 阅读器)、.md markdown-it 渲染、.txt/.log 纯文本、常见图片居中展示 | | 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 | | 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) | | 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) | diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 3758079..75a24bb 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -639,27 +639,18 @@ 原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动—— 向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目 展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑, -## 补记(常驻项目彻底去特殊化:变更视图通用 / 真删除 / 去箭头) +## 补记(本地站点目录级托管:资源管理器点 index.html 完整渲染相对资源) -- 用户反馈(四条):① 两个常驻项目也要变更视图按钮;② 怀疑底层代码里写死了这两个项目的 - 特殊内容——应该完全不特殊;③ 删除常驻项目后「恢复默认」不应复活它们(真删除), - 但整个流程可逆(可重新添加回来);④ 管理行去掉 ↑↓ 箭头(左缘 ≡ 抓手拖拽已够用)。 -- 处理: - ① 变更视图通用化:projects.v1 新增 views(id → LayoutSpec 视图覆盖);管理行 🧩 按钮 - 所有项目显示;applyLayoutChange 对布局项目重建条目、对入驻项目写 views[id]; - openSplit 打开时用 views[spec.id] 替换自声明布局;卡片点击拦截(有覆盖时用引擎打开), - 常驻项目一样可变更视图; - ② 去硬编码:删除 LEGACY_SPLIT_IDS/ta_card/pr_card 类名映射;改为通用 DOM 桥—— - 子座位卡片按注册序 ↔ aliveRegisteredIds 位置映射(button:not(.dsh-wt_layout)), - 标记 data-wt-id;图标覆盖写到卡片第一个子元素;CSS 全部换成 [data-wt-id] 通用选择器 - (框/高度/字号/字重/描述行隐藏);reportUsed 冷却对全部非引擎项目通用; - split.tsx 仅保留跨插件互操作桥(.ta_split 浮层让位,注释中性化); - ③ 删除语义:resetProjects 不再清空 removed(恢复默认不复活);设置面板新增「已删除的项目」 - 区块,逐条「↺ 重新添加」(删除 ↔ 重新添加完全可逆);confirm.projectBody 文案同步; - ④ 管理行删除 ↑/↓ 按钮与 moveBy 函数,排序只靠 ≡ 拖拽;locale 删 manage.up/down, - 新增 manage.removed/readd。 -- 验证(functional-diag STEP9/10 重写):STEP9 常驻行 3 按钮(无箭头+🧩)→ 变更视图 - views['planreview'] 三栏拓扑 ✓;布局行迁移不变 ✓;STEP10 删除后 reset removedAfterReset - 仍含 id(不复活)→ removedSection=true → ↺ 重新添加 removed=[] 且卡片回归 ✓; - 通用桥回归:框/高度/字重/图标覆盖/desc 隐藏全 ✓;ERRORS_COUNT: 0。 -- 备注:纯客户端改动,F5 即生效,无需重启 dsh web。 +- 用户问题:AI 乐(静态内容 HTML)点 index.html 能渲染,旅行 Atlas(JS 单页应用)白屏。 +- 根因:旧预览走 /api/worktable/file 单文件直读,页内相对引用(./assets/...)解析到错误目录 404; + AI 乐 HTML 自带静态内容所以仍可见,旅行 Atlas 只有空 root + JS 注入所以白屏。 +- 处理(用户确认方案 1): + ① 服务端新增前缀路由 /api/worktable/site(kind:'prefix',模式照搬 better-sidebar): + URL = /site//<相对路径>,逐段 decode,root 目录内沙箱(.. 越界 403), + 目录访问回退 index.html;40MB 上限;FILE_TYPES 共享 MIME 表扩充(字体/wasm/音视频/avif 等); + ② 客户端资源管理器点 .html/.htm 改开 site URL(root=所在目录,rel=文件名),标签标题=文件名 + (SplitContent iframe 增加可选 title 字段);单文件预览(md/txt/pdf/图片)不变。 +- 验证(functional-diag STEP12 + 04_test/fixture-site 夹具:index.html + assets/app.js/style.css): + 预检路由存在才断言(appText=SITE_OK + CSS 颜色注入)。当前运行中的服务器未加载新路由 → + routeReady=false(404 已过滤,ERRORS_COUNT: 0),其余 STEP1-11 全绿。 +- 备注:含服务端路由,**需手动重启一次 dsh web** 后 STEP12 完整验证与旅行 Atlas dist 实测。 diff --git a/04_test/fixture-site/assets/app.js b/04_test/fixture-site/assets/app.js new file mode 100644 index 0000000..bac1dde --- /dev/null +++ b/04_test/fixture-site/assets/app.js @@ -0,0 +1 @@ +document.getElementById('app').textContent = 'SITE_OK'; diff --git a/04_test/fixture-site/assets/style.css b/04_test/fixture-site/assets/style.css new file mode 100644 index 0000000..054d0be --- /dev/null +++ b/04_test/fixture-site/assets/style.css @@ -0,0 +1 @@ +#app { color: rgb(216, 72, 82); } diff --git a/04_test/fixture-site/index.html b/04_test/fixture-site/index.html new file mode 100644 index 0000000..23413ae --- /dev/null +++ b/04_test/fixture-site/index.html @@ -0,0 +1,12 @@ + + + + + Fixture Site + + + +
LOADING
+ + + diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs index 3cfc0ee..ae5d7ed 100644 --- a/04_test/functional-diag.cjs +++ b/04_test/functional-diag.cjs @@ -304,8 +304,41 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); })()`); console.log('STEP11:', JSON.stringify(step11)); + // 本地站点目录级托管:index.html 的相对资源(./assets/...)应随目录一起解析渲染 + const step12 = await evaluate(`(async function(){ + var out={}; + try{ + var st=window.__dshWorktable.splitStore; + st.open({id:'t-site',title:'site',top:null,main:[{id:'s1',title:'s',min:200,content:null}],chatWidth:{default:320,min:240,max:600}}); + await new Promise(function(r){setTimeout(r,400)}); + var rootToken=encodeURIComponent('E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\04_test\\\\fixture-site'); + var pr=await fetch('/api/worktable/site/'+rootToken+'/index.html').catch(function(e){ return null; }); + out.routeReady=!!(pr&&pr.ok); + out.probeStatus=pr?pr.status:null; + if(out.routeReady){ + st.openTab('main',0,{kind:'iframe',url:'/api/worktable/site/'+rootToken+'/index.html',title:'site'}); + await new Promise(function(r){setTimeout(r,1800)}); + out.tabTitle=st.spec.main[0].tabs[0].title; + var f=document.querySelector('.dsh-wt_paneFrame'); + var doc=f&&f.contentDocument; + if(doc){ + var app=doc.getElementById('app'); + out.appText=app?app.textContent:null; + out.appColor=app?doc.defaultView.getComputedStyle(app).color:null; + } + } + st.close(); + }catch(err){ out.err=String(err) } + return JSON.stringify(out); + })()`); + console.log('STEP12:', JSON.stringify(step12)); const errors = events.filter((e) => { + // 站点路由属新增服务端能力:运行中的服务器未重启前该 404 属预期(重启后移除本过滤) + const ent = e.method === 'Log.entryAdded' ? e.params.entry : null; + const txt = ent ? (ent.text || '') + '|' + (ent.url || '') : ''; + if (txt.indexOf('/api/worktable/site') >= 0) return false; + return true; }).filter((e) => e.method === 'Runtime.exceptionThrown' || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||