feat: 五个功能窗第一版(资源管理器/SCM/任务/终端/浏览器,照搬 better-sidebar 架构)

This commit is contained in:
Aisland
2026-08-17 11:47:35 +08:00
parent 86c9904b42
commit 981c2acfb3
10 changed files with 463 additions and 19 deletions
+1 -1
View File
@@ -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',
+14 -1
View File
@@ -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"
}
}
}
+2 -1
View File
@@ -51,6 +51,7 @@
}
},
"devDependencies": {
"esbuild": "^0.25.0"
"esbuild": "^0.25.0",
"xterm": "^5.3.0"
}
}
+17 -1
View File
@@ -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<string | null>(() =>
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<any[]>([])
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<FloatRect | null>(null)
const persistView = (patch: Partial<ViewState>) => {
+12
View File
@@ -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',
+217 -4
View File
@@ -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<any> {
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<any[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [history, setHistory] = useState<string[]>([])
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 (
<>
<div className="dsh-wt_subBar">
<button type="button" className="dsh-wt_subBtn" title="上一级" onClick={() => load(parentPathOf(path), true)}>⬆</button>
<button type="button" className="dsh-wt_subBtn" title="后退" onClick={back}>↩</button>
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={() => load(path, false)}>↻</button>
<span className="dsh-wt_subPath">{path || '…'}</span>
</div>
<div className="dsh-wt_subList">
{error && <div className="dsh-wt_subEmpty">{error}</div>}
{!error && !loading && entries.length === 0 && <div className="dsh-wt_subEmpty">—</div>}
{entries.map((e) => (
<button
key={e.path}
type="button"
className="dsh-wt_subRow"
onClick={() => (e.isDir ? load(e.path, true) : setError(T('pane.openLater')))}
>
<span className="dsh-wt_subIcon" aria-hidden>{e.isDir ? '📁' : '📄'}</span>
<span className="dsh-wt_subName">{e.name}</span>
</button>
))}
</div>
</>
)
}
/** 源代码管理窗(服务端 /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 (
<>
<div className="dsh-wt_subBar">
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={load}>↻</button>
<span className="dsh-wt_subPath">{snap?.isRepo ? ('⎇ ' + snap.branch) : ''}</span>
</div>
<div className="dsh-wt_subList">
{error && <div className="dsh-wt_subEmpty">{error}</div>}
{!error && snap && !snap.isRepo && <div className="dsh-wt_subEmpty">{T('pane.gitNotRepo')}</div>}
{!error && snap?.isRepo && snap.entries.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.gitClean')}</div>}
{!error && snap?.isRepo && snap.entries.map((e, i) => (
<div key={i} className="dsh-wt_subRow dsh-wt_subRowStatic">
<span className={'dsh-wt_gitXY dsh-wt_gitXY' + (e.xy.includes('A') || e.xy.includes('M') ? 'Mod' : 'New')}>{e.xy.trim()}</span>
<span className="dsh-wt_subName">{e.path}</span>
</div>
))}
</div>
</>
)
}
/** 任务管理窗(客户端 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 (
<div className="dsh-wt_subList">
{jobs.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.jobsEmpty')}</div>}
{jobs.map((j) => (
<div key={j.id} className="dsh-wt_subRow dsh-wt_subRowStatic">
<span className={'dsh-wt_jobDot dsh-wt_jobDot-' + j.status} aria-hidden>●</span>
<span className="dsh-wt_subName">{j.label}</span>
<span className="dsh-wt_subTag">{j.kind}</span>
</div>
))}
</div>
)
}
/** 终端窗(WS /api/worktable/term + node-pty) */
function TerminalPane() {
const hostRef = useRef<HTMLDivElement | null>(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 <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{failed}</span></div>
}
return <div ref={hostRef} className="dsh-wt_termHost" />
}
/** 窗内容三态渲染 */
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 <iframe className="dsh-wt_paneFrame" src={content.url} title={pane.title} />
}
if (content && content.kind === 'builtin' && content.type === 'browser') {
return <BrowserPane />
}
if (content && content.kind === 'builtin') {
if (content.type === 'browser') return <BrowserPane />
if (content.type === 'explorer') return <ExplorerPane />
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
+25
View File
@@ -127,4 +127,29 @@ export const css = [
// + 右侧弹出面板
'.dsh-wt_popBackdrop{position:fixed;left:0;top:0;right:0;bottom:0;z-index:79;background:transparent}',
'.dsh-wt_pop{max-height:min(540px,calc(100vh - 32px));overflow:auto;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
// 功能窗(资源管理器/SCM/任务/终端)
'.dsh-wt_subBar{flex:none;display:flex;align-items:center;gap:4px;padding:3px 6px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
'.dsh-wt_subBtn{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:11px;cursor:pointer}',
'.dsh-wt_subBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_subPath{flex:1;min-width:0;font-size:10.5px;color:var(--dsw-alias-label-tertiary,#6e7683);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_subList{flex:1;min-height:0;overflow:auto;padding:3px}',
'.dsh-wt_subRow{display:flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;padding:3px 6px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:16px;cursor:pointer;text-align:left}',
'.dsh-wt_subRow:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_subRowStatic{cursor:default}',
'.dsh-wt_subRowStatic:hover{background:transparent}',
'.dsh-wt_subIcon{flex:none;font-size:11px}',
'.dsh-wt_subName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_subEmpty{padding:14px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary,#6e7683);text-align:center}',
'.dsh-wt_subTag{flex:none;font-size:9px;color:var(--dsw-alias-label-tertiary,#6e7683);padding:0 5px;border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_gitXY{flex:none;width:20px;font-size:9px;text-align:center;border-radius:4px;padding:1px 0}',
'.dsh-wt_gitXYMod{color:#e5b567;background:rgba(229,181,103,.12)}',
'.dsh-wt_gitXYNew{color:#7fd88f;background:rgba(127,216,143,.12)}',
'.dsh-wt_jobDot{flex:none;font-size:8px;line-height:16px}',
'.dsh-wt_jobDot-running{color:#e5b567}',
'.dsh-wt_jobDot-completed{color:#7fd88f}',
'.dsh-wt_jobDot-failed{color:#e5484d}',
'.dsh-wt_jobDot-killed{color:#6e7683}',
'.dsh-wt_jobDot-stopping{color:#9aa4b2}',
'.dsh-wt_termHost{flex:1;min-height:0;padding:6px 2px 2px 8px;background:#010409;overflow:hidden}',
'.dsh-wt_termHost .xterm{height:100%}',
].join('\n')
+145 -5
View File
@@ -1,24 +1,164 @@
import { Context } from '@deepseek-ai/cordis'
import { execFile } from 'node:child_process'
import { readdir } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
/**
* dsh-worktable 服务端:健康路由 + 工作区内容窗的数据路由。
* 参考 dsh-better-sidebar 的架构——内容窗能力由本插件自己的服务端路由提供:
* - POST /api/worktable/fs 目录列表(资源管理器窗)
* - POST /api/worktable/git git 状态(源代码管理窗)
* - WS /api/worktable/term node-pty 终端流(终端窗;依赖宿主 node_modules 中的
* node-pty 与 ws,缺失时该路由不注册、终端窗降级提示)
*/
export const name = 'dsh-worktable'
/** 依赖 webServer 服务(web profile 由 dsh-web-app 挂载) */
export const inject = ['webServer']
export const HEALTH_PATH = '/api/worktable/health'
const MAX_ENTRIES = 500
function json(res: any, status: number, body: unknown) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(body))
}
async function readJsonBody(req: any): Promise<any> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
const text = Buffer.concat(chunks).toString('utf8')
if (!text) return {}
try { return JSON.parse(text) } catch { return {} }
}
/** 列出一个目录层级(目录在前、大小写不敏感排序、上限 500、隐藏项标注) */
async function listDirectory(path: string) {
const abs = resolve(path)
const dirents = await readdir(abs, { withFileTypes: true })
const entries = dirents
.map((d) => ({ name: d.name, path: abs + sep + d.name, isDir: d.isDirectory(), hidden: d.name.startsWith('.') }))
.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
})
const truncated = entries.length > MAX_ENTRIES
return { path: abs, entries: truncated ? entries.slice(0, MAX_ENTRIES) : entries, truncated }
}
function gitExec(args: string[], cwd: string): Promise<string> {
return new Promise((resolvePromise, reject) => {
execFile('git', args, { cwd, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) reject(err)
else resolvePromise(stdout)
})
})
}
/** git 状态快照(porcelain v1 -z;非仓库返回 isRepo:false) */
async function gitStatus(cwd: string) {
try {
const branchRaw = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
const porcelain = await gitExec(['status', '--porcelain=v1', '-z'], cwd)
const entries = porcelain
.split('\0')
.filter((s) => s.length > 2)
.map((s) => ({ xy: s.slice(0, 2), path: s.slice(3) }))
return { isRepo: true, branch: branchRaw.trim() || 'HEAD', entries }
} catch {
return { isRepo: false, branch: undefined, entries: [] }
}
}
/** 终端 WebSocket 升级路由(node-pty 缺失时不注册,终端窗降级为提示) */
async function setupTerminal(webServer: any) {
if (typeof webServer.registerUpgrade !== 'function') return
let wsMod: any = null
let ptyMod: any = null
try { wsMod = await import('ws') } catch {}
try { ptyMod = await import('node-pty') } catch {}
if (!wsMod || !ptyMod) return
const WebSocketServer = wsMod.WebSocketServer ?? wsMod.default?.WebSocketServer
if (!WebSocketServer) return
const pty = ptyMod.default ?? ptyMod
const wss = new WebSocketServer({ noServer: true })
const spawnShell = (): string =>
process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/bash')
const clampDim = (v: number, fallback: number) => Math.min(1024, Math.max(2, Number.isFinite(v) ? v : fallback))
webServer.registerUpgrade({
path: '/api/worktable/term',
handler: (req: any, socket: any, head: any) => {
wss.handleUpgrade(req, socket, head, (ws: any) => {
const u = new URL(req.url ?? '/', 'http://dsh.internal')
const cwd = u.searchParams.get('cwd') || process.cwd()
const cols = clampDim(Number(u.searchParams.get('cols')), 80)
const rows = clampDim(Number(u.searchParams.get('rows')), 24)
let term: any = null
try {
term = pty.spawn(spawnShell(), [], { name: 'xterm-256color', cols, rows, cwd, env: process.env })
} catch (err) {
try { ws.send('\r\n[worktable] 终端启动失败:' + String(err)) } catch {}
try { ws.close() } catch {}
return
}
term.onData((d: string) => { try { ws.send(d) } catch {} })
term.onExit(() => { try { ws.close() } catch {} })
ws.on('message', (raw: any) => {
const text = String(raw)
try {
const msg = JSON.parse(text)
if (msg && msg.type === 'resize' && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
term.resize(clampDim(msg.cols, cols), clampDim(msg.rows, rows))
return
}
} catch {}
try { term.write(text) } catch {}
})
ws.on('close', () => { try { term.kill() } catch {} })
})
},
})
}
export function apply(ctx: Context) {
const webServer = (ctx as any).webServer
if (!webServer) {
ctx.logger?.warn('[dsh-worktable] ctx.webServer 不可用(headless profile?),跳过健康路由')
ctx.logger?.warn('[dsh-worktable] ctx.webServer 不可用(headless profile?),跳过服务端路由')
return
}
webServer.register({
kind: 'exact',
path: HEALTH_PATH,
handler: (_req: any, res: any) => {
const body = JSON.stringify({ plugin: 'dsh-worktable', version: '0.1.0', ok: true })
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(body)
json(res, 200, { plugin: 'dsh-worktable', version: '0.2.0', ok: true })
},
})
webServer.register({
kind: 'exact',
path: '/api/worktable/fs',
handler: async (req: any, res: any) => {
try {
const body = await readJsonBody(req)
const path = typeof body.path === 'string' && body.path ? body.path : process.cwd()
json(res, 200, await listDirectory(path))
} catch (err) {
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
}
},
})
webServer.register({
kind: 'exact',
path: '/api/worktable/git',
handler: async (req: any, res: any) => {
const body = await readJsonBody(req)
const cwd = typeof body.cwd === 'string' && body.cwd ? body.cwd : process.cwd()
json(res, 200, await gitStatus(cwd))
},
})
setupTerminal(webServer).catch(() => {})
}
+6 -6
View File
@@ -351,12 +351,12 @@ type SplitPane = {
| 内容窗 | 可行性 | 依据 |
| --- | --- | --- |
| 浏览器 | ✅ 现成 | iframe + 同源路由(travelatlas 已验证) |
| 资源管理器 | 🔶 待调研 | workspaces/directory 服务与官方目录选择器可作基底 |
| 终端 | 🔶 待调研 | 宿主依赖含 xterm + dsh-terminal 包,接入方式需逆向确认 |
| 任务管理 | 🔶 待调研 | todo/目标状态投影服务,需找对 API |
| 源代码管理 | ⚠️ 受限 | 宿主无内置 git 服务;先以「终端里跑 git」替代 |
| 自定义 vibe | ✅ 可闭环 | 描述需求 → agent 生成新项目(插件/站点)→ 注册进工作台 |
| 浏览器 | ✅ 已实现 | iframe + 地址栏 |
| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件打开待后续 |
| 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 |
| 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) |
| 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) |
| 自定义 vibe | ✅ 可闭环 | 描述需求 → agent 生成新项目(插件/站点)→ 注册进工作台(UI 已留 ✨ 入口) |
### 13.8 里程碑
@@ -333,3 +333,27 @@
该布局支持 ⇄ 翻转(聊天通高贴左时内容区在右)。
- 预设:t3 的 chatFull=true;缩略图改为左品字 + 右通高聊天;词典 preset.t3 改名「左品右聊 / Pin + chat」。
- 验证:构建 + node --check 通过;服务端实时下发(含 chatFullHeight);待用户刷新实测。
## 补记(5 个功能窗照搬 better-sidebar 架构,第一版实现)
- 用户要求:参照已安装的 dsh-better-sidebar,把前 5 个内容窗(浏览器/资源管理器/
源代码管理/任务管理/终端)做成真正生效的,自定义暂缓。
- 调研结论(better-sidebar 架构):内容窗能力 = 它自己服务端的路由(fs 列表、git 命令、
node-pty + WebSocket 升级路由、任务回放)+ 客户端 xterm/iframe;注册 Upgrade 走
ctx.webServer.registerUpgrade。
- 实现(dsh-worktable):
① 服务端重写(src/index.ts):POST /api/worktable/fs(readdir 目录优先排序、上限 500)、
POST /api/worktable/git(status porcelain v1 -z + 分支)、WS /api/worktable/term
(node-pty 生成 shell + resize 协议;node-pty/ws 缺失时路由不注册、终端窗降级提示);
build.mjs 服务端 external 增加 ws/node-pty(运行时从宿主 node_modules 解析,
better-sidebar 已带)。
② 客户端(split.tsx):ExplorerPane(面包屑路径/上一级/后退/刷新/目录进入)、
GitPane(分支 + 变更清单,XY 着色)、JobsPane(sessions 快照 jobsBySession 列表、
状态圆点、2s 刷新)、TerminalPane(xterm + WS + ResizeObserver fit/resize);
新增 SplitEnv(setSplitEnv 注入 getScope/getJobs),工作台从 useSessions 快照取
当前会话与 cwd、jobsBySession。
③ 依赖:devDependencies 增加 xterm ^5.3.0(已 npm install,打包进 client.js)。
- 注意:服务端路由改动需**重启 dsh web 后生效**(bundle 启动时组合;客户端已实时下发)。
未安排自动重启(用户要求,其他窗口仍在工作)。
- 待后续:文件打开/编辑、SCM diff/暂存/提交、终端 cwd 信任客户端(better-sidebar 用
服务端 header.cwd,后续对齐)、jobs 输出回放。