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
|
||||
|
||||
Reference in New Issue
Block a user