= {
function tabTitleOf(content: SplitContent): string {
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
+ if (content.kind === 'file') return basenameOf(content.path)
try {
const u = new URL(content.url)
return u.hostname || content.url
@@ -133,6 +136,20 @@ function tabTitleOf(content: SplitContent): string {
}
}
+/** 取路径最后一段作为标签标题 */
+function basenameOf(p: string): string {
+ const parts = String(p).replace(/[\\/]+$/, '').split(/[\\/]/)
+ return parts[parts.length - 1] || String(p)
+}
+
+/** 内容同一性(openTab 去重:同窗内同内容只保留一个标签,再次打开切过去) */
+function sameContent(a: SplitContent, b: SplitContent): boolean {
+ if (a.kind === 'iframe' && b.kind === 'iframe') return a.url === b.url
+ if (a.kind === 'file' && b.kind === 'file') return a.path === b.path
+ if (a.kind === 'builtin' && b.kind === 'builtin') return a.type === b.type
+ return false
+}
+
/** 分栏 UI 文案提供者(由工作台注入 locale t) */
let uiT: ((key: string) => string) | null = null
export function setSplitT(fn: ((key: string) => string) | null) {
@@ -546,6 +563,9 @@ export const splitStore: SplitState = {
if (!spec) return
const mutate = (pane: SplitPane): SplitPane => {
const tabs = [...(pane.tabs ?? [])]
+ // 去重:同内容已有标签 → 直接激活
+ const existing = tabs.findIndex((t) => sameContent(t.content, content))
+ if (existing >= 0) return { ...pane, content: null, tabs, active: existing }
const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content }
tabs.push(tab)
return { ...pane, content: null, tabs, active: tabs.length - 1 }
@@ -910,6 +930,8 @@ function ExplorerPane(props: { row: PaneRow; index: number }) {
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) })
+ } 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 {
setError(T('pane.openLater'))
}
@@ -1070,12 +1092,80 @@ function TerminalPane() {
return
}
+const mdRenderer = new MarkdownIt({ linkify: true })
+
+const IMAGE_EXTS = /[.](png|jpe?g|gif|webp|svg|bmp|ico)$/i
+const MD_EXTS = /[.](md|markdown|mdown)$/i
+
+/** 本地文件预览:PDF 走原生 iframe、图片居中展示、MD 渲染预览、其余按纯文本 */
+function FileViewer(props: { path: string }) {
+ const ext = (props.path.split('.').pop() || '').toLowerCase()
+ const fileUrl = '/api/worktable/file?path=' + encodeURIComponent(props.path)
+ if (ext === 'pdf') {
+ return
+ }
+ if (IMAGE_EXTS.test('.' + ext)) {
+ return (
+
+

+
+ )
+ }
+ return
+}
+
+/** MD/TXT 文本预览(fetch 原文 → MD 渲染或 等宽展示) */
+function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
+ const [text, setText] = useState(null)
+ const [error, setError] = useState('')
+ useEffect(() => {
+ let dead = false
+ setText(null)
+ setError('')
+ fetch(props.fileUrl)
+ .then((r) => {
+ if (!r.ok) throw new Error('HTTP ' + r.status)
+ return r.text()
+ })
+ .then((t) => { if (!dead) setText(t) })
+ .catch((e) => { if (!dead) setError(String(e)) })
+ return () => { dead = true }
+ }, [props.fileUrl])
+ if (error) {
+ return {T('file.fail')}:{error}
+ }
+ if (text == null) {
+ return {T('file.loading')}
+ }
+ if (props.isMd) {
+ return (
+
+
{
+ const a = e.target && e.target.closest ? (e.target.closest('a') as HTMLAnchorElement | null) : null
+ if (!a) return
+ e.preventDefault()
+ const href = a.getAttribute('href') || ''
+ if (/^(https?:|mailto:)/i.test(href)) window.open(href, '_blank', 'noopener')
+ }}
+ />
+
+ )
+ }
+ return
+}
+
/** 单个标签页的内容渲染 */
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
const content = props.tab.content
if (content.kind === 'iframe') {
return
}
+ if (content.kind === 'file') {
+ return
+ }
if (content.type === 'browser') return
if (content.type === 'explorer') return
if (content.type === 'scm') return
diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts
index 9d117be..c1f3a7c 100644
--- a/01_content/src/client/styles.ts
+++ b/01_content/src/client/styles.ts
@@ -76,6 +76,30 @@ export const css = [
'.dsh-wt_splitClose{flex:none;display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:12px;cursor:pointer}',
'.dsh-wt_splitClose:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden;transition:border-color .15s ease,box-shadow .15s ease,transform .15s ease}',
+ // 文件预览:文本/MD 滚动容器 + MD 排版 + 图片居中
+ '.dsh-wt_fileView{flex:1;min-height:0;overflow:auto;padding:12px 14px;background:var(--dsw-alias-bg-base,#0b0e14);color:var(--dsw-alias-label-primary,#e6e8eb);font-size:12.5px;line-height:1.7}',
+ '.dsh-wt_txt{margin:0;font-family:var(--dsw-font-mono,Consolas,Menlo,monospace);font-size:12px;line-height:1.65;white-space:pre-wrap;word-break:break-word}',
+ '.dsh-wt_md h1,.dsh-wt_md h2,.dsh-wt_md h3,.dsh-wt_md h4{margin:14px 0 8px;line-height:1.3;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb)}',
+ '.dsh-wt_md h1{font-size:20px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);padding-bottom:6px}',
+ '.dsh-wt_md h2{font-size:16.5px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);padding-bottom:5px}',
+ '.dsh-wt_md h3{font-size:14.5px}',
+ '.dsh-wt_md h4{font-size:13px}',
+ '.dsh-wt_md p{margin:6px 0}',
+ '.dsh-wt_md a{color:var(--dsw-alias-state-accent-primary,#4f8ef7);text-decoration:none}',
+ '.dsh-wt_md a:hover{text-decoration:underline}',
+ '.dsh-wt_md code{font-family:var(--dsw-font-mono,Consolas,Menlo,monospace);font-size:11.5px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06));padding:1px 5px;border-radius:4px}',
+ '.dsh-wt_md pre{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.04));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;padding:10px 12px;overflow:auto;margin:8px 0}',
+ '.dsh-wt_md pre code{background:transparent;padding:0;font-size:12px}',
+ '.dsh-wt_md blockquote{margin:8px 0;padding:2px 12px;border-left:3px solid var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));color:var(--dsw-alias-label-secondary,#9aa4b2)}',
+ '.dsh-wt_md ul,.dsh-wt_md ol{margin:6px 0;padding-left:22px}',
+ '.dsh-wt_md li{margin:2px 0}',
+ '.dsh-wt_md table{border-collapse:collapse;margin:8px 0;font-size:12px}',
+ '.dsh-wt_md th,.dsh-wt_md td{border:1px solid var(--dsw-alias-border-l1,#262b36);padding:4px 10px;text-align:left}',
+ '.dsh-wt_md th{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
+ '.dsh-wt_md hr{border:none;border-top:1px solid var(--dsw-alias-border-l1,#262b36);margin:12px 0}',
+ '.dsh-wt_md img{max-width:100%}',
+ '.dsh-wt_imgView{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:auto;padding:10px;background:var(--dsw-alias-bg-base,#0b0e14)}',
+ '.dsh-wt_imgView img{max-width:100%;max-height:100%;object-fit:contain}',
'.dsh-wt_pane[data-drop-hover=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 2px rgba(79,142,247,.4),var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4));transform:scale(1.012)}',
'.dsh-wt_paneBar{flex:none;display:flex;align-items:center;height:22px;padding:0 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
'.dsh-wt_paneTitle{flex:1;min-width:0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
diff --git a/01_content/src/index.ts b/01_content/src/index.ts
index e81d7d3..e7e82ec 100644
--- a/01_content/src/index.ts
+++ b/01_content/src/index.ts
@@ -208,8 +208,8 @@ export function apply(ctx: Context) {
const 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', md: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
- svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
+ 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.end(data)
diff --git a/02_process/PRD.md b/02_process/PRD.md
index fcf544a..a724074 100644
--- a/02_process/PRD.md
+++ b/02_process/PRD.md
@@ -335,8 +335,9 @@ type SplitPane = {
视口内钳制;透明遮罩点击关闭),不再使用侧边栏内展开式下拉;面板内容仅「选择布局 + 填名称」
(快捷方式表单暂移除,存量快捷方式条目仍保留展示/删除);
- **拓扑预设六个**(2 行 × 3 列缩略图网格,聊天窗蓝色 💬 标注):
- 左右两栏 / 三栏横排 / 左一右二 / 上一下二 / 井字四栏 / 左品右聊(2026-08-16 定案);
- 左一右二 = 左列整高内容窗 + 右列「上内容 + 下聊天」(左列布局聊天固定右下,无 ⇄ 翻转);
+ 左右两栏 / 三栏横排 / 左二右一 / 上一下二 / 井字四栏 / 左品右聊(2026-08-17 更新:
+ 第三个预设由「左一右二」改为「左二右一」);
+ 左二右一 = 左侧上下两个内容窗 + 右侧聊天通高整列(chatFullHeight 几何,聊天可 ⇄ 翻转贴左);
左品右聊 = 左侧品字形(上一个、下两个内容窗)+ 右侧聊天通高整列(chatFullHeight 几何,
聊天可 ⇄ 翻转贴左);
- 保存的布局以「布局条目」形式出现在项目区(布局 = 一种工作台项目),点击打开 tiling 工作区,
diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md
index e6de638..6788003 100644
--- a/02_process/worklogs/2026-08-16_worktable-v2.md
+++ b/02_process/worklogs/2026-08-16_worktable-v2.md
@@ -597,3 +597,21 @@
- 打点:git tag checkpoint-2026-08-17-pre-fileviewer(已推远端,b22cd6b)。
- 状态:工作台 UI/交互(卡片统一、图标、弹窗、删除确认、互斥、预设均衡、picker 自适应、
分隔线居中)全部完成并验证;下一个大块 = 文件预览(MD/TXT/PDF 等自定义开发)。
+## 补记(文件预览 MD/TXT/PDF/图片 + 预设三改左二右一)
+
+- 用户确认默认方案开工 + 第 3 个预设(左一右二)改为「左边两个 + 右边一个对话」。
+- 文件预览实现:
+ ① SplitContent 新增 kind:'file'(path);tabTitleOf 取 basename;openTab 去重
+ (同内容已有标签 → 直接激活,sameContent 判定);
+ ② FileViewer:pdf → iframe 原生渲染(服务端 MIME application/pdf);图片 → 居中 img;
+ md/markdown → markdown-it(打进 client.js,linkify 开、内嵌 HTML 关闭,链接点击新标签外开);
+ 其余 → 等宽 纯文本;fetch 失败/加载中文案(file.fail/file.loading);
+ ③ 资源管理器点击 .md/.markdown/.mdown/.txt/.log/.pdf/图片 开预览标签(.html 行为不变,
+ 其他类型提示不支持);服务端 file 路由 MIME 补 pdf/markdown/log/bmp/ico;
+ ④ 全套暗色 MD 排版样式(标题/代码块/引用/表格/列表/分割线)。
+- 预设 l2 改造:PRESET_DEFS l2 → {top:1, content:1, chatFull:true}(左列上下两内容窗 + 右列通高聊天),
+ 缩略图改为左 2 右 1;PRD §13 预设说明同步更新。
+- 验证(functional-diag STEP11):openTab kind:file PRD.md → 标签名 PRD.md、.dsh-wt_md 渲染出
+ PRD 开头文本;同路径再开 tabsAfterDup=1(去重);package.json → .dsh-wt_txt 展示;
+ 全 STEP 回归 ERROR_COUNT: 0。bundle-eval 补 atob/btoa 桩后 PASS(markdown-it 引入)。
+- 备注:客户端改动 F5 生效;服务端 pdf MIME 需手动重启 dsh web 一次后 PDF 预览才生效。
diff --git a/04_test/bundle-eval.cjs b/04_test/bundle-eval.cjs
index 8e20a76..93bb2b3 100644
--- a/04_test/bundle-eval.cjs
+++ b/04_test/bundle-eval.cjs
@@ -27,6 +27,7 @@ const sandbox = { self: null,
addEventListener(){}, removeEventListener(){},
},
localStorage: { getItem(){ return null }, setItem(){}, removeItem(){} },
+ atob: (s) => Buffer.from(s, 'base64').toString('binary'), btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
CustomEvent: class { constructor(t, o){ this.type = t; this.detail = o && o.detail } },
ResizeObserver: class { observe(){} disconnect(){} },
MutationObserver: class { observe(){} disconnect(){} },
diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs
index 189d363..66aae0d 100644
--- a/04_test/functional-diag.cjs
+++ b/04_test/functional-diag.cjs
@@ -250,6 +250,28 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
})()`);
console.log('STEP10:', JSON.stringify(step10));
+ // 文件预览:MD 渲染 / TXT 文本 / 标签去重
+ const step11 = await evaluate(`(async function(){
+ var out={};
+ var st=window.__dshWorktable.splitStore;
+ st.open({id:'t-file',title:'file',top:null,main:[{id:'f1',title:'f',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
+ await new Promise(function(r){setTimeout(r,400)});
+ st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\02_process\\\\PRD.md'});
+ await new Promise(function(r){setTimeout(r,1200)});
+ out.mdTitle=st.spec.main[0].tabs[0].title;
+ out.mdView=!!document.querySelector('.dsh-wt_md');
+ out.mdText=document.querySelector('.dsh-wt_md')?String(document.querySelector('.dsh-wt_md').textContent).slice(0,50):null;
+ st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\02_process\\\\PRD.md'});
+ out.tabsAfterDup=st.spec.main[0].tabs.length;
+ st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\package.json'});
+ await new Promise(function(r){setTimeout(r,900)});
+ out.txtView=!!document.querySelector('.dsh-wt_txt');
+ out.tabsNow=st.spec.main[0].tabs.length;
+ st.close();
+ return JSON.stringify(out);
+ })()`);
+ console.log('STEP11:', JSON.stringify(step11));
+
const errors = events.filter((e) =>
e.method === 'Runtime.exceptionThrown' ||
(e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||