feat: 树形资源管理器(SVG图标/懒展开) + 任务窗子代理区 + 终端依赖loadPkg解析修复

This commit is contained in:
Aisland
2026-08-17 12:28:32 +08:00
parent e2b43c475b
commit eabd367e48
6 changed files with 193 additions and 52 deletions
+10 -2
View File
@@ -231,7 +231,7 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [],
/** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */
const sessionScopeStore: {
snapshot: { sessionId: string; cwd: string; jobs: any[] } | null
snapshot: { sessionId: string; cwd: string; jobs: any[]; subagents: any[] } | null
} = { snapshot: null }
function syncSessionScope(list: any) {
@@ -239,13 +239,20 @@ function syncSessionScope(list: any) {
const snap = list.getSnapshot()
const current: string = snap?.current ?? ''
const entry = snap?.items?.find((it: any) => it.sessionId === current) ?? null
const cat = snap?.subagentsByParent?.[current]
let subagents: any[] = []
if (Array.isArray(cat)) subagents = cat
else if (cat && Array.isArray(cat.entries)) subagents = cat.entries
else if (cat && Array.isArray(cat.items)) subagents = cat.items
else if (cat && Array.isArray(cat.children)) subagents = cat.children
sessionScopeStore.snapshot = {
sessionId: current,
cwd: entry?.cwd ?? '',
jobs: (snap?.jobsBySession?.[current] ?? []) as any[],
subagents,
}
} catch {
sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [] }
sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [], subagents: [] }
}
}
@@ -301,6 +308,7 @@ function WorktableSection(props: any) {
return s ? { sessionId: s.sessionId, cwd: s.cwd } : null
},
getJobs: () => sessionScopeStore.snapshot?.jobs ?? [],
getSubagents: () => sessionScopeStore.snapshot?.subagents ?? [],
})
return () => setSplitEnv(null)
}, [])
+6
View File
@@ -62,6 +62,9 @@ export const zh = {
'pane.jobsEmpty': '当前会话暂无后台任务',
'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)',
'pane.closeTab': '关闭标签页',
'pane.jobsTitle': '后台任务',
'pane.subagents': '子代理',
'pane.subagentsEmpty': '当前会话暂无子代理',
'split.flip': '切换对话窗位置(左⇄右)',
'split.dragSwap': '拖动标题栏与其他窗交换位置',
'empty': '暂无项目',
@@ -128,6 +131,9 @@ export const en = {
'pane.jobsEmpty': 'No background jobs in the current session',
'pane.termFail': 'Terminal unavailable (host missing node-pty/ws, or connection failed)',
'pane.closeTab': 'Close tab',
'pane.jobsTitle': 'Background jobs',
'pane.subagents': 'Subagents',
'pane.subagentsEmpty': 'No subagents in the current session',
'split.flip': 'Flip chat side (left ⇄ right)',
'split.dragSwap': 'Drag the title bar to swap panes',
'empty': 'No projects yet',
+118 -47
View File
@@ -151,6 +151,7 @@ export type SplitJob = {
type SplitEnv = {
getScope: () => SplitScope | null
getJobs: () => SplitJob[]
getSubagents: () => any[]
}
let splitEnv: SplitEnv | null = null
export function setSplitEnv(env: SplitEnv | null) {
@@ -722,63 +723,122 @@ function BrowserPane() {
)
}
/** 资源管理器窗(服务端 /api/worktable/fs) */
/** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */
function FolderIcon() {
return (
<svg className="dsh-wt_treeIcon" width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M1.75 3.25A1.75 1.75 0 0 1 3.5 1.5h2.63a1.75 1.75 0 0 1 1.34.66l.62.79a1.75 1.75 0 0 0 1.34.66H12.5a1.75 1.75 0 0 1 1.75 1.75v7.39A1.75 1.75 0 0 1 12.5 14.5h-9a1.75 1.75 0 0 1-1.75-1.75V3.25Z" fill="var(--dsw-alias-state-accent-primary,#4f8ef7)" opacity="0.9" />
<path d="M1.75 5.75h12.5v7a1.75 1.75 0 0 1-1.75 1.75h-9a1.75 1.75 0 0 1-1.75-1.75v-7Z" fill="var(--dsw-alias-state-accent-primary,#4f8ef7)" opacity="0.4" />
</svg>
)
}
/** 文件图标(重绘 SVG) */
function FileIcon() {
return (
<svg className="dsh-wt_treeIcon" width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M4 1.5h5.25a1 1 0 0 1 .71.29l3.25 3.25a1 1 0 0 1 .29.71V13.5a1 1 0 0 1-1 1h-8.5a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1Z" fill="var(--dsw-alias-fill-l1,rgba(255,255,255,.06))" stroke="var(--dsw-alias-label-secondary,#9aa4b2)" strokeWidth="1.1" />
<path d="M9.25 1.5V4.75h3.25" fill="none" stroke="var(--dsw-alias-label-secondary,#9aa4b2)" strokeWidth="1.1" />
</svg>
)
}
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用) */
function ExplorerPane() {
const [path, setPath] = useState('')
const [entries, setEntries] = useState<any[]>([])
const cacheRef = useRef<Record<string, any[]>>({})
const expandedRef = useRef<Set<string>>(new Set())
const [rootPath, setRootPath] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [history, setHistory] = useState<string[]>([])
const load = useCallback((p: string, push: boolean) => {
setLoading(true)
postJson('/api/worktable/fs', {
path: p,
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
.then((d) => {
setPath(d.path ?? p)
setEntries(d.entries ?? [])
setError('')
setHistory((h) => (push && p !== d.path ? [...h, p] : h))
const [, setTick] = useState(0)
const rerender = () => setTick((t) => t + 1)
const fetchDir = useCallback(async (path: string, force = false) => {
if (!force && cacheRef.current[path]) return { path, entries: cacheRef.current[path] }
try {
const d = await postJson('/api/worktable/fs', {
path,
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
const entries: any[] = d.entries ?? []
cacheRef.current[d.path] = entries
setError(d.error ? String(d.error) : '')
return { path: d.path, entries }
} catch (e) {
setError(String(e))
return { path, entries: [] }
} finally {
rerender()
}
}, [])
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)
})
const initRoot = useCallback(async () => {
const r = await fetchDir(splitEnv?.getScope()?.cwd ?? '')
setRootPath(r.path)
rerender()
}, [fetchDir])
useEffect(() => { initRoot() }, [initRoot])
const toggle = (path: string) => {
if (expandedRef.current.has(path)) expandedRef.current.delete(path)
else { expandedRef.current.add(path); fetchDir(path) }
rerender()
}
const refresh = () => {
cacheRef.current = {}
expandedRef.current.clear()
setError('')
initRoot()
}
const goUp = () => {
if (!rootPath) return
const parent = parentPathOf(rootPath)
if (parent === rootPath) return
cacheRef.current = {}
expandedRef.current.clear()
setError('')
fetchDir(parent).then((r) => { setRootPath(r.path); rerender() })
}
const renderLevel = (path: string, depth: number): any[] => {
const entries = cacheRef.current[path]
if (!entries) return []
const nodes: any[] = []
for (const e of entries) {
const isOpen = expandedRef.current.has(e.path)
nodes.push(
<div key={e.path}>
<button
type="button"
className="dsh-wt_treeRow"
style={{ paddingLeft: 8 + depth * 14 }}
onClick={() => (e.isDir ? toggle(e.path) : setError(T('pane.openLater')))}
>
<span className={'dsh-wt_treeArrow' + (e.isDir && isOpen ? ' dsh-wt_treeArrowOpen' : '')} aria-hidden>{e.isDir ? '▸' : ''}</span>
{e.isDir ? <FolderIcon /> : <FileIcon />}
<span className="dsh-wt_treeName">{e.name}</span>
</button>
{e.isDir && isOpen && renderLevel(e.path, depth + 1)}
</div>,
)
}
return nodes
}
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>
<button type="button" className="dsh-wt_subBtn" title="上一级" onClick={goUp}>⬆</button>
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={refresh}>↻</button>
<span className="dsh-wt_subPath">{rootPath || '…'}</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>
))}
{!error && cacheRef.current[rootPath]?.length === 0 && <div className="dsh-wt_subEmpty">—</div>}
{renderLevel(rootPath, 0)}
</div>
</>
)
@@ -818,7 +878,7 @@ function GitPane() {
)
}
/** 任务管理窗(客户端 sessions 快照 jobsBySession;2s 刷新) */
/** 任务管理窗:后台任务 + 子代理(Agent 情况;2s 刷新) */
function JobsPane() {
const [, setTick] = useState(0)
useEffect(() => {
@@ -826,8 +886,10 @@ function JobsPane() {
return () => window.clearInterval(timer)
}, [])
const jobs = splitEnv?.getJobs?.() ?? []
const subagents = splitEnv?.getSubagents?.() ?? []
return (
<div className="dsh-wt_subList">
<div className="dsh-wt_subSection">{T('pane.jobsTitle')}</div>
{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">
@@ -836,6 +898,15 @@ function JobsPane() {
<span className="dsh-wt_subTag">{j.kind}</span>
</div>
))}
<div className="dsh-wt_subSection">{T('pane.subagents')}</div>
{subagents.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.subagentsEmpty')}</div>}
{subagents.map((s: any, i: number) => (
<div key={s?.id ?? i} className="dsh-wt_subRow dsh-wt_subRowStatic" style={{ paddingLeft: 8 + (s?.depth ?? 0) * 12 }}>
<span className={'dsh-wt_jobDot dsh-wt_jobDot-' + (s?.status ?? 'stopping')} aria-hidden>●</span>
<span className="dsh-wt_subName">{s?.label ?? s?.title ?? s?.name ?? '—'}</span>
{s?.status && <span className="dsh-wt_subTag">{s.status}</span>}
</div>
))}
</div>
)
}
+8
View File
@@ -160,4 +160,12 @@ export const css = [
'.dsh-wt_tabTitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_tabClose{flex:none;width:14px;height:14px;padding:0;border:none;border-radius:3px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:9px;line-height:1;cursor:pointer}',
'.dsh-wt_tabClose:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08))}',
// 树形资源管理器
'.dsh-wt_treeRow{display:flex;align-items:center;gap:5px;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_treeRow:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_treeArrow{flex:none;width:10px;font-size:8px;color:var(--dsw-alias-label-tertiary,#6e7683);transition:transform .12s ease}',
'.dsh-wt_treeArrowOpen{transform:rotate(90deg)}',
'.dsh-wt_treeIcon{flex:none}',
'.dsh-wt_treeName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_subSection{flex:none;padding:6px 8px 2px;font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#6e7683)}',
].join('\n')
+32 -3
View File
@@ -1,7 +1,9 @@
import { Context } from '@deepseek-ai/cordis'
import { execFile } from 'node:child_process'
import { readdir } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
import { readdir, realpathSync } from 'node:fs'
import { dirname, resolve as pathResolve, sep } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
/**
* dsh-worktable 服务端:健康路由 + 工作区内容窗的数据路由。
@@ -19,6 +21,28 @@ export const HEALTH_PATH = '/api/worktable/health'
const MAX_ENTRIES = 500
/**
* 从本插件模块位置向祖先方向查找并加载 node_modules 包(如 ws / node-pty)。
* 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖;
* 同时尝试 junction 路径与 realpath 两条祖先链。
*/
function loadPkg(pkg: string): any | null {
const starts = new Set<string>()
try { starts.add(dirname(fileURLToPath(import.meta.url))) } catch {}
try { starts.add(realpathSync(dirname(fileURLToPath(import.meta.url)))) } catch {}
for (const start of starts) {
let dir: string | null = start
while (dir && dir !== pathResolve(dir, '..')) {
try {
const req = createRequire(pathToFileURL(pathResolve(dir, '__wt_probe__.js')).href)
return req(pkg)
} catch {}
dir = pathResolve(dir, '..')
}
}
return null
}
/** 解析会话工作目录:服务端 header.cwd 优先,其次客户端传入 cwd,最后进程 cwd */
function serverCwd(ctx: any, sessionId?: string, clientCwd?: string): string {
if (sessionId) {
@@ -89,7 +113,12 @@ async function setupTerminal(webServer: any, ctx: any) {
let ptyMod: any = null
try { wsMod = await import('ws') } catch {}
try { ptyMod = await import('node-pty') } catch {}
if (!wsMod || !ptyMod) return
if (!wsMod) wsMod = loadPkg('ws')
if (!ptyMod) ptyMod = loadPkg('node-pty')
if (!wsMod || !ptyMod) {
ctx.logger?.warn('[dsh-worktable] 终端路由未注册:ws/node-pty 均不可用')
return
}
const WebSocketServer = wsMod.WebSocketServer ?? wsMod.default?.WebSocketServer
if (!WebSocketServer) return
const pty = ptyMod.default ?? ptyMod
@@ -389,3 +389,22 @@
③ 任务窗数据源不变(jobsBySession,无后台任务时显示空态文案)。
- 验证:构建 + node --check 通过;headless Chrome 回归零报错、区块正常渲染。
- 注意:服务端 cwd 解析需**重启 dsh web 后生效**;标签页模型为客户端改动、刷新即生效。
## 补记(对照 better-sidebar 源码重做:树形资源管理器 / 子代理任务窗 / 终端依赖解析)
- 用户反馈:① 资源管理器刷新/后退按钮失效(只有上一级能用);② 终端空白;③ 任务窗
没有 Agent 情况;④ 要求对照 better-sidebar 源码重做(树形展开 + 重绘图标)。
- 调研:better-sidebar 客户端 16px 描边 SVG 图标 + 子代理树(aria-expanded、缩进、当前高亮);
服务端 inject ['webServer','sessions','webRuntime','tools']。
- 实现:
① 资源管理器重写为树形:懒加载子目录(缓存 + 展开集)、▸/▾ 旋转箭头、重绘文件夹/文件
SVG 图标、缩进层级;刷新(清缓存重载根)与上一级(根上移)修复可用(旧版在 setState
更新器里做副作用导致按钮失效,已避免)。
② 任务窗增加「子代理」区(sessions 快照 subagentsByParent[current],防御式取数,
状态圆点 + 缩进),与后台任务并列。
③ 终端空白根因:服务端 import('node-pty')/import('ws') 失败——本包经 junction 链接,
模块真实路径在工作区,向上找不到 profile 级依赖。修复:loadPkg 沿「junction 路径 +
realpath」两条祖先链 createRequire 查找并加载;已本地验证 junction 路径可解析
node-pty/ws。**需重启 dsh web 后终端路由才注册。**
- 验证:构建 + node --check 通过;headless Chrome 回归零报错;依赖解析测试通过。
- 差距(M3 待续):文件打开/编辑器、SCM diff/暂存/提交、任务输出回放。