diff --git a/01_content/package-lock.json b/01_content/package-lock.json index 3641522..440d28e 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "esbuild": "^0.25.0", + "markdown-it": "^15.0.0", "xterm": "^5.3.0" }, "peerDependencies": { @@ -471,6 +472,36 @@ "node": ">=18" } }, + "node_modules/argparse": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-3.0.0.tgz", + "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "Python-2.0" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", @@ -513,6 +544,78 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, + "node_modules/markdown-it": { + "version": "15.0.0", + "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-15.0.0.tgz", + "integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "dev": true, + "license": "MIT" + }, "node_modules/xterm": { "version": "5.3.0", "resolved": "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz", diff --git a/01_content/package.json b/01_content/package.json index 2d1e41f..17a177d 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -36,8 +36,8 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "*", - "@deepseek-ai/dsh-host-webserver": "*", - "@deepseek-ai/dsh-client-locale": "*" + "@deepseek-ai/dsh-client-locale": "*", + "@deepseek-ai/dsh-host-webserver": "*" }, "peerDependenciesMeta": { "@deepseek-ai/cordis": { @@ -52,6 +52,7 @@ }, "devDependencies": { "esbuild": "^0.25.0", + "markdown-it": "^15.0.0", "xterm": "^5.3.0" } } diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 6236182..4bf8eee 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -72,7 +72,7 @@ const OVER_SIDE_PX = 80 const PRESET_DEFS = [ { id: '2h', leftCount: 0, topCount: 0, contentCount: 1, chatFull: false }, { id: '3h', leftCount: 0, topCount: 0, contentCount: 2, chatFull: false }, - { id: 'l2', leftCount: 1, topCount: 1, contentCount: 0, chatFull: false }, + { id: 'l2', leftCount: 0, topCount: 1, contentCount: 1, chatFull: true }, { id: 't2', leftCount: 0, topCount: 1, contentCount: 1, chatFull: false }, { id: 'grid', leftCount: 0, topCount: 2, contentCount: 1, chatFull: false }, { id: 't3', leftCount: 0, topCount: 1, contentCount: 2, chatFull: true }, @@ -153,13 +153,14 @@ function presetThumb(defId: string) { ) } if (defId === 'l2') { + // 左二右一:左侧上下两个内容窗,右侧整列对话(💬) return ( - {cell(false, 'a')} + {cell(false, 'a')} {cell(false, 'b')} - {cell(true, 'c')} + {cell(true, 'c')} ) } diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 7d11c8e..86ab84e 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -61,7 +61,9 @@ export const zh = { 'pane.open': '打开', 'pane.wip': '开发中(后续版本接入)', 'pane.explorerNoCwd': '当前会话没有工作目录', - 'pane.openLater': '文件打开将在后续版本支持', + 'pane.openLater': '该类型暂不支持预览', + 'file.loading': '加载中…', + 'file.fail': '文件读取失败', 'pane.gitNotRepo': '当前目录不是 git 仓库', 'pane.gitClean': '工作区干净', 'pane.jobsEmpty': '当前会话暂无后台任务', @@ -137,7 +139,9 @@ export const en = { 'pane.open': 'Open', 'pane.wip': 'Coming in a later version', 'pane.explorerNoCwd': 'No working directory for the current session', - 'pane.openLater': 'Opening files comes in a later version', + 'pane.openLater': 'Preview not supported for this type', + 'file.loading': 'Loading…', + 'file.fail': 'Failed to read file', 'pane.gitNotRepo': 'Not a git repository', 'pane.gitClean': 'Working tree clean', 'pane.jobsEmpty': 'No background jobs in the current session', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 6035081..a7e3e37 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Terminal } from 'xterm' import 'xterm/css/xterm.css' +import MarkdownIt from 'markdown-it' /** * dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。 @@ -17,6 +18,7 @@ export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal' export type SplitContent = | { kind: 'iframe'; url: string } | { kind: 'builtin'; type: BuiltinType } + | { kind: 'file'; path: string } /** 一个内容标签页 */ export type PaneTab = { id: string; title: string; content: SplitContent } @@ -125,6 +127,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) 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