feat: 树形资源管理器(SVG图标/懒展开) + 任务窗子代理区 + 终端依赖loadPkg解析修复
This commit is contained in:
@@ -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)
|
||||
}, [])
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/暂存/提交、任务输出回放。
|
||||
|
||||
Reference in New Issue
Block a user