From 981c2acfb3eb7c973e14f12f70576c679be2b578 Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 11:47:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=94=E4=B8=AA=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=AA=97=E7=AC=AC=E4=B8=80=E7=89=88=EF=BC=88=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8/SCM/=E4=BB=BB=E5=8A=A1/=E7=BB=88?= =?UTF-8?q?=E7=AB=AF/=E6=B5=8F=E8=A7=88=E5=99=A8=EF=BC=8C=E7=85=A7?= =?UTF-8?q?=E6=90=AC=20better-sidebar=20=E6=9E=B6=E6=9E=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/build.mjs | 2 +- 01_content/package-lock.json | 15 +- 01_content/package.json | 3 +- 01_content/src/client/index.tsx | 18 +- 01_content/src/client/locales.ts | 12 + 01_content/src/client/split.tsx | 221 +++++++++++++++++- 01_content/src/client/styles.ts | 25 ++ 01_content/src/index.ts | 150 +++++++++++- 02_process/PRD.md | 12 +- .../worklogs/2026-08-16_worktable-v2.md | 24 ++ 10 files changed, 463 insertions(+), 19 deletions(-) diff --git a/01_content/build.mjs b/01_content/build.mjs index 3b93788..77b25b2 100644 --- a/01_content/build.mjs +++ b/01_content/build.mjs @@ -33,7 +33,7 @@ const jobs = [ { entryPoints: [join(here, 'src/index.ts')], outfile: 'lib/index.js', platform: 'node', format: 'esm', target: ['node22'], - external: ['@deepseek-ai/*', 'node:*'], + external: ['@deepseek-ai/*', 'node:*', 'ws', 'node-pty'], }, { entryPoints: [join(here, 'src/client/index.tsx')], outfile: 'lib/client.js', diff --git a/01_content/package-lock.json b/01_content/package-lock.json index ff6ad2d..3641522 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -9,16 +9,21 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "esbuild": "^0.25.0" + "esbuild": "^0.25.0", + "xterm": "^5.3.0" }, "peerDependencies": { "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-locale": "*", "@deepseek-ai/dsh-host-webserver": "*" }, "peerDependenciesMeta": { "@deepseek-ai/cordis": { "optional": true }, + "@deepseek-ai/dsh-client-locale": { + "optional": true + }, "@deepseek-ai/dsh-host-webserver": { "optional": true } @@ -507,6 +512,14 @@ "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" } + }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "dev": true, + "license": "MIT" } } } diff --git a/01_content/package.json b/01_content/package.json index bb753aa..2d1e41f 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -51,6 +51,7 @@ } }, "devDependencies": { - "esbuild": "^0.25.0" + "esbuild": "^0.25.0", + "xterm": "^5.3.0" } } diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index e09d47d..3fa6b7b 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' -import { splitStore, SplitWorkspace, setSplitT, type LayoutSpec, type SplitPane } from './split' +import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane } from './split' /** * dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。 @@ -270,6 +270,22 @@ function WorktableSection(props: any) { const [activeSplitId, setActiveSplitId] = useState(() => splitStore.active && splitStore.spec ? splitStore.spec.id : null, ) + + // 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境 + const useSessions = typeof props.useSessions === 'function' ? props.useSessions : null + const sessionsSnap = useSessions ? useSessions() : null + const currentEntry = sessionsSnap?.items?.find((it: any) => it.sessionId === sessionsSnap?.current) ?? null + const scopeRef = useRef<{ sessionId: string; cwd: string }>({ sessionId: '', cwd: '' }) + const jobsRef = useRef([]) + scopeRef.current = { sessionId: sessionsSnap?.current ?? '', cwd: currentEntry?.cwd ?? '' } + jobsRef.current = sessionsSnap?.jobsBySession?.[sessionsSnap?.current] ?? [] + useEffect(() => { + setSplitEnv({ + getScope: () => scopeRef.current, + getJobs: () => jobsRef.current, + }) + return () => setSplitEnv(null) + }, []) const floatRef = useRef(null) const persistView = (patch: Partial) => { diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 445189a..81ad276 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -55,6 +55,12 @@ export const zh = { 'pane.customUrlPh': '地址(/xxx/ 或 https://)', 'pane.open': '打开', 'pane.wip': '开发中(后续版本接入)', + 'pane.explorerNoCwd': '当前会话没有工作目录', + 'pane.openLater': '文件打开将在后续版本支持', + 'pane.gitNotRepo': '当前目录不是 git 仓库', + 'pane.gitClean': '工作区干净', + 'pane.jobsEmpty': '当前会话暂无后台任务', + 'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)', 'split.flip': '切换对话窗位置(左⇄右)', 'split.dragSwap': '拖动标题栏与其他窗交换位置', 'empty': '暂无项目', @@ -114,6 +120,12 @@ export const en = { 'pane.customUrlPh': 'URL (/xxx/ or https://)', '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.gitNotRepo': 'Not a git repository', + 'pane.gitClean': 'Working tree clean', + 'pane.jobsEmpty': 'No background jobs in the current session', + 'pane.termFail': 'Terminal unavailable (host missing node-pty/ws, or connection failed)', 'split.flip': 'Flip chat side (left ⇄ right)', 'split.dragSwap': 'Drag the title bar to swap panes', 'empty': 'No projects yet', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 65134b9..b22f0d2 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Terminal } from 'xterm' +import 'xterm/css/xterm.css' /** * dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。 @@ -101,6 +103,44 @@ export function setSplitT(fn: ((key: string) => string) | null) { } const T = (key: string): string => (uiT ? uiT(key) : key) +/** 工作区环境(由工作台注入:当前会话作用域与后台任务列表) */ +export type SplitScope = { sessionId: string; cwd: string } +export type SplitJob = { + id: string + kind: string + label: string + status: string + detail?: string + startedAt: number + finishedAt?: number +} +type SplitEnv = { + getScope: () => SplitScope | null + getJobs: () => SplitJob[] +} +let splitEnv: SplitEnv | null = null +export function setSplitEnv(env: SplitEnv | null) { + splitEnv = env +} + +async function postJson(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error('HTTP ' + res.status) + return res.json() +} + +function parentPathOf(p: string): string { + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + if (idx <= 0) return p + let parent = p.slice(0, idx) + if (/^[A-Za-z]:$/.test(parent)) parent += '\\' + return parent +} + /** 拖拽换位暂存 */ let dragPane: { row: PaneRow; index: number } | null = null @@ -575,6 +615,177 @@ function BrowserPane() { ) } +/** 资源管理器窗(服务端 /api/worktable/fs) */ +function ExplorerPane() { + const [path, setPath] = useState('') + const [entries, setEntries] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [history, setHistory] = useState([]) + const load = useCallback((p: string, push: boolean) => { + if (!p) { setError(T('pane.explorerNoCwd')); return } + setLoading(true) + postJson('/api/worktable/fs', { path: p }) + .then((d) => { + setPath(d.path ?? p) + setEntries(d.entries ?? []) + setError('') + setHistory((h) => (push && p !== d.path ? [...h, p] : h)) + }) + .catch((e) => setError(String(e))) + .finally(() => setLoading(false)) + }, []) + useEffect(() => { + load(splitEnv?.getScope()?.cwd ?? '', false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const back = () => { + setHistory((h) => { + if (h.length === 0) return h + const prev = h[h.length - 1] + load(prev, false) + return h.slice(0, -1) + }) + } + return ( + <> +
+ + + + {path || '…'} +
+
+ {error &&
{error}
} + {!error && !loading && entries.length === 0 &&
—
} + {entries.map((e) => ( + + ))} +
+ + ) +} + +/** 源代码管理窗(服务端 /api/worktable/git) */ +function GitPane() { + const [snap, setSnap] = useState<{ isRepo: boolean; branch?: string; entries: any[] } | null>(null) + const [error, setError] = useState('') + const load = useCallback(() => { + const cwd = splitEnv?.getScope()?.cwd ?? '' + if (!cwd) { setError(T('pane.explorerNoCwd')); return } + postJson('/api/worktable/git', { cwd }) + .then(setSnap) + .catch((e) => setError(String(e))) + }, []) + useEffect(() => { load() }, [load]) + return ( + <> +
+ + {snap?.isRepo ? ('⎇ ' + snap.branch) : ''} +
+
+ {error &&
{error}
} + {!error && snap && !snap.isRepo &&
{T('pane.gitNotRepo')}
} + {!error && snap?.isRepo && snap.entries.length === 0 &&
{T('pane.gitClean')}
} + {!error && snap?.isRepo && snap.entries.map((e, i) => ( +
+ {e.xy.trim()} + {e.path} +
+ ))} +
+ + ) +} + +/** 任务管理窗(客户端 sessions 快照 jobsBySession;2s 刷新) */ +function JobsPane() { + const [, setTick] = useState(0) + useEffect(() => { + const timer = window.setInterval(() => setTick((t) => t + 1), 2000) + return () => window.clearInterval(timer) + }, []) + const jobs = splitEnv?.getJobs?.() ?? [] + return ( +
+ {jobs.length === 0 &&
{T('pane.jobsEmpty')}
} + {jobs.map((j) => ( +
+ ● + {j.label} + {j.kind} +
+ ))} +
+ ) +} + +/** 终端窗(WS /api/worktable/term + node-pty) */ +function TerminalPane() { + const hostRef = useRef(null) + const [failed, setFailed] = useState('') + useEffect(() => { + const el = hostRef.current + if (!el) return + let term: any = null + let ws: WebSocket | null = null + let disposed = false + try { + term = new Terminal({ + cursorBlink: true, + fontFamily: 'Consolas, Menlo, monospace', + fontSize: 12, + convertEol: true, + theme: { background: '#010409' }, + }) + } catch { + setFailed(T('pane.termFail')) + return + } + term.open(el) + const scope = splitEnv?.getScope?.() + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = proto + '//' + location.host + '/api/worktable/term?cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24' + try { + ws = new WebSocket(url) + } catch { + term.dispose() + setFailed(T('pane.termFail')) + return + } + ws.onmessage = (ev) => { try { term.write(String(ev.data)) } catch {} } + ws.onclose = () => { if (!disposed) { try { term.write('\r\n[连接已关闭]') } catch {} } } + ws.onerror = () => { if (!disposed) setFailed(T('pane.termFail')) } + term.onData((d: string) => { if (ws && ws.readyState === 1) ws.send(d) }) + const ro = new ResizeObserver(() => { + if (typeof term.fit === 'function') { + try { term.fit() } catch {} + if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + }) + ro.observe(el) + return () => { + disposed = true + ro.disconnect() + try { ws?.close() } catch {} + try { term.dispose() } catch {} + } + }, []) + if (failed) { + return
{failed}
+ } + return
+} + /** 窗内容三态渲染 */ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) { const { pane, row, index } = props @@ -582,10 +793,12 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) { if (content && content.kind === 'iframe') { return