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
+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>
)
}