feat: 五个功能窗第一版(资源管理器/SCM/任务/终端/浏览器,照搬 better-sidebar 架构)
This commit is contained in:
@@ -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',
|
||||
|
||||
Generated
+14
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
"esbuild": "^0.25.0",
|
||||
"xterm": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 输出回放。
|
||||
|
||||
Reference in New Issue
Block a user