feat: 本地站点目录级托管(/api/worktable/site前缀路由) 资源管理器点html完整渲染相对资源
This commit is contained in:
@@ -16,7 +16,7 @@ import MarkdownIt from 'markdown-it'
|
|||||||
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
|
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
|
||||||
|
|
||||||
export type SplitContent =
|
export type SplitContent =
|
||||||
| { kind: 'iframe'; url: string }
|
| { kind: 'iframe'; url: string; title?: string }
|
||||||
| { kind: 'builtin'; type: BuiltinType }
|
| { kind: 'builtin'; type: BuiltinType }
|
||||||
| { kind: 'file'; path: string }
|
| { kind: 'file'; path: string }
|
||||||
|
|
||||||
@@ -128,6 +128,7 @@ const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
|
|||||||
function tabTitleOf(content: SplitContent): string {
|
function tabTitleOf(content: SplitContent): string {
|
||||||
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
|
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
|
||||||
if (content.kind === 'file') return basenameOf(content.path)
|
if (content.kind === 'file') return basenameOf(content.path)
|
||||||
|
if (content.kind === 'iframe' && content.title) return content.title
|
||||||
try {
|
try {
|
||||||
const u = new URL(content.url)
|
const u = new URL(content.url)
|
||||||
return u.hostname || content.url
|
return u.hostname || content.url
|
||||||
@@ -929,7 +930,13 @@ function ExplorerPane(props: { row: PaneRow; index: number }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (e.isDir) { toggle(e.path); return }
|
if (e.isDir) { toggle(e.path); return }
|
||||||
if (/\.html?$/i.test(e.name)) {
|
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)) {
|
} 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 })
|
splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+50
-1
@@ -23,6 +23,21 @@ export const HEALTH_PATH = '/api/worktable/health'
|
|||||||
|
|
||||||
const MAX_ENTRIES = 500
|
const MAX_ENTRIES = 500
|
||||||
|
|
||||||
|
/** 本地文件/站点静态资源的 MIME 映射(file 与 site 两条路由共用) */
|
||||||
|
const FILE_TYPES: Record<string, string> = {
|
||||||
|
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)。
|
* 从本插件模块位置向祖先方向查找并加载 node_modules 包(如 ws / node-pty)。
|
||||||
* 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖;
|
* 本包经 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',
|
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',
|
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/... 等相对引用正常解析(前缀路由,余下路径 = <rootToken>/<相对路径>)。
|
||||||
|
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)
|
res.end(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, 404, { error: String(err) })
|
json(res, 404, { error: String(err) })
|
||||||
|
|||||||
+1
-1
@@ -353,7 +353,7 @@ type SplitPane = {
|
|||||||
| 内容窗 | 可行性 | 依据 |
|
| 内容窗 | 可行性 | 依据 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 浏览器 | ✅ 已实现 | iframe + 地址栏 |
|
| 浏览器 | ✅ 已实现 | 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/暂存/提交待后续 |
|
| 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 |
|
||||||
| 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) |
|
| 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) |
|
||||||
| 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) |
|
| 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) |
|
||||||
|
|||||||
@@ -639,27 +639,18 @@
|
|||||||
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
|
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
|
||||||
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
|
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
|
||||||
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
|
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
|
||||||
## 补记(常驻项目彻底去特殊化:变更视图通用 / 真删除 / 去箭头)
|
## 补记(本地站点目录级托管:资源管理器点 index.html 完整渲染相对资源)
|
||||||
|
|
||||||
- 用户反馈(四条):① 两个常驻项目也要变更视图按钮;② 怀疑底层代码里写死了这两个项目的
|
- 用户问题:AI 乐(静态内容 HTML)点 index.html 能渲染,旅行 Atlas(JS 单页应用)白屏。
|
||||||
特殊内容——应该完全不特殊;③ 删除常驻项目后「恢复默认」不应复活它们(真删除),
|
- 根因:旧预览走 /api/worktable/file 单文件直读,页内相对引用(./assets/...)解析到错误目录 404;
|
||||||
但整个流程可逆(可重新添加回来);④ 管理行去掉 ↑↓ 箭头(左缘 ≡ 抓手拖拽已够用)。
|
AI 乐 HTML 自带静态内容所以仍可见,旅行 Atlas 只有空 root + JS 注入所以白屏。
|
||||||
- 处理:
|
- 处理(用户确认方案 1):
|
||||||
① 变更视图通用化:projects.v1 新增 views(id → LayoutSpec 视图覆盖);管理行 🧩 按钮
|
① 服务端新增前缀路由 /api/worktable/site(kind:'prefix',模式照搬 better-sidebar):
|
||||||
所有项目显示;applyLayoutChange 对布局项目重建条目、对入驻项目写 views[id];
|
URL = /site/<encodeURIComponent(目录)>/<相对路径>,逐段 decode,root 目录内沙箱(.. 越界 403),
|
||||||
openSplit 打开时用 views[spec.id] 替换自声明布局;卡片点击拦截(有覆盖时用引擎打开),
|
目录访问回退 index.html;40MB 上限;FILE_TYPES 共享 MIME 表扩充(字体/wasm/音视频/avif 等);
|
||||||
常驻项目一样可变更视图;
|
② 客户端资源管理器点 .html/.htm 改开 site URL(root=所在目录,rel=文件名),标签标题=文件名
|
||||||
② 去硬编码:删除 LEGACY_SPLIT_IDS/ta_card/pr_card 类名映射;改为通用 DOM 桥——
|
(SplitContent iframe 增加可选 title 字段);单文件预览(md/txt/pdf/图片)不变。
|
||||||
子座位卡片按注册序 ↔ aliveRegisteredIds 位置映射(button:not(.dsh-wt_layout)),
|
- 验证(functional-diag STEP12 + 04_test/fixture-site 夹具:index.html + assets/app.js/style.css):
|
||||||
标记 data-wt-id;图标覆盖写到卡片第一个子元素;CSS 全部换成 [data-wt-id] 通用选择器
|
预检路由存在才断言(appText=SITE_OK + CSS 颜色注入)。当前运行中的服务器未加载新路由 →
|
||||||
(框/高度/字号/字重/描述行隐藏);reportUsed 冷却对全部非引擎项目通用;
|
routeReady=false(404 已过滤,ERRORS_COUNT: 0),其余 STEP1-11 全绿。
|
||||||
split.tsx 仅保留跨插件互操作桥(.ta_split 浮层让位,注释中性化);
|
- 备注:含服务端路由,**需手动重启一次 dsh web** 后 STEP12 完整验证与旅行 Atlas dist 实测。
|
||||||
③ 删除语义: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。
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
document.getElementById('app').textContent = 'SITE_OK';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#app { color: rgb(216, 72, 82); }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Fixture Site</title>
|
||||||
|
<link rel="stylesheet" href="./assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">LOADING</div>
|
||||||
|
<script src="./assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -304,8 +304,41 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||||||
})()`);
|
})()`);
|
||||||
console.log('STEP11:', JSON.stringify(step11));
|
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) => {
|
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) =>
|
}).filter((e) =>
|
||||||
e.method === 'Runtime.exceptionThrown' ||
|
e.method === 'Runtime.exceptionThrown' ||
|
||||||
(e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||
|
(e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||
|
||||||
|
|||||||
Reference in New Issue
Block a user