feat: 内容窗收敛4项 + 终端loadPkg修复(E2E验证) + 文件路由 + 标签跨窗拖动吸附 + 自动化测试
This commit is contained in:
@@ -91,6 +91,7 @@ type SplitState = {
|
||||
openTab(row: PaneRow, i: number, content: SplitContent): void
|
||||
closeTab(row: PaneRow, i: number, tabId: string): void
|
||||
setActiveTab(row: PaneRow, i: number, tabId: string): void
|
||||
moveTab(fromRow: PaneRow, fromI: number, tabId: string, toRow: PaneRow, toI: number): void
|
||||
swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void
|
||||
setChatSide(side: 'left' | 'right'): void
|
||||
persist(): void
|
||||
@@ -178,6 +179,16 @@ function parentPathOf(p: string): string {
|
||||
|
||||
/** 拖拽换位暂存 */
|
||||
let dragPane: { row: PaneRow; index: number } | null = null
|
||||
/** 标签拖拽暂存(跨窗移动) */
|
||||
let dragTab: { row: PaneRow; index: number; tabId: string } | null = null
|
||||
/** 标签拖放目标(吸附高亮;模块级,PaneBody 与窗容器共用) */
|
||||
let dropTarget: { row: PaneRow; index: number } | null = null
|
||||
let dropTargetListeners: Set<() => void> = new Set()
|
||||
function setDropTarget(t: { row: PaneRow; index: number } | null) {
|
||||
if (dropTarget === t) return
|
||||
dropTarget = t
|
||||
for (const fn of dropTargetListeners) fn()
|
||||
}
|
||||
|
||||
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
|
||||
function findConversationRoot(): HTMLElement | null {
|
||||
@@ -266,7 +277,7 @@ export const splitStore: SplitState = {
|
||||
window.dispatchEvent(new CustomEvent('dsh:split-claim', { detail: { id: spec.id } }))
|
||||
} catch {}
|
||||
const root = findConversationRoot()
|
||||
if (!root || root.dataset.phase !== 'active') return false
|
||||
if (!root) return false
|
||||
const header = root.children[0] as HTMLElement | undefined
|
||||
const viewArea = root.children[1] as HTMLElement | undefined
|
||||
if (!header || !viewArea) return false
|
||||
@@ -544,6 +555,36 @@ export const splitStore: SplitState = {
|
||||
this.notify()
|
||||
},
|
||||
|
||||
moveTab(fromRow, fromI, tabId, toRow, toI) {
|
||||
const spec = this.spec
|
||||
if (!spec) return
|
||||
if (fromRow === toRow && fromI === toI) return
|
||||
const top = [...(spec.top ?? [])]
|
||||
const main = [...spec.main]
|
||||
const left = spec.left ? { ...spec.left } : null
|
||||
const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main)
|
||||
const fromArr = arrOf(fromRow)
|
||||
const toArr = arrOf(toRow)
|
||||
const fromPane = fromArr[fromI]
|
||||
const toPane = toArr[toI]
|
||||
if (!fromPane || !toPane) return
|
||||
const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId)
|
||||
if (!tab) return
|
||||
const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId)
|
||||
const toTabs = [...(toPane.tabs ?? []), tab]
|
||||
const setPane = (row: PaneRow, i: number, pane: SplitPane) => {
|
||||
if (row === 'left') spec.left = pane
|
||||
else if (row === 'top') top[i] = pane
|
||||
else main[i] = pane
|
||||
}
|
||||
setPane(fromRow, fromI, { ...fromPane, tabs: fromTabs, active: 0 })
|
||||
setPane(toRow, toI, { ...toPane, tabs: toTabs, active: toTabs.length - 1 })
|
||||
this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, main }
|
||||
this.onSpecMutated?.(this.spec)
|
||||
this.persist()
|
||||
this.notify()
|
||||
},
|
||||
|
||||
setActiveTab(row, i, tabId) {
|
||||
const spec = this.spec
|
||||
if (!spec) return
|
||||
@@ -743,8 +784,8 @@ function FileIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用) */
|
||||
function ExplorerPane() {
|
||||
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用;.html 点击开浏览器标签) */
|
||||
function ExplorerPane(props: { row: PaneRow; index: number }) {
|
||||
const cacheRef = useRef<Record<string, any[]>>({})
|
||||
const expandedRef = useRef<Set<string>>(new Set())
|
||||
const [rootPath, setRootPath] = useState('')
|
||||
@@ -815,7 +856,14 @@ function ExplorerPane() {
|
||||
type="button"
|
||||
className="dsh-wt_treeRow"
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
onClick={() => (e.isDir ? toggle(e.path) : setError(T('pane.openLater')))}
|
||||
onClick={() => {
|
||||
if (e.isDir) { toggle(e.path); return }
|
||||
if (/\.html?$/i.test(e.name)) {
|
||||
splitStore.openTab(props.row, props.index, { kind: 'iframe', url: '/api/worktable/file?path=' + encodeURIComponent(e.path) })
|
||||
} else {
|
||||
setError(T('pane.openLater'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={'dsh-wt_treeArrow' + (e.isDir && isOpen ? ' dsh-wt_treeArrowOpen' : '')} aria-hidden>{e.isDir ? '▸' : ''}</span>
|
||||
{e.isDir ? <FolderIcon /> : <FileIcon />}
|
||||
@@ -969,13 +1017,13 @@ function TerminalPane() {
|
||||
}
|
||||
|
||||
/** 单个标签页的内容渲染 */
|
||||
function PaneTabBody(props: { tab: PaneTab }) {
|
||||
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
|
||||
const content = props.tab.content
|
||||
if (content.kind === 'iframe') {
|
||||
return <iframe className="dsh-wt_paneFrame" src={content.url} title={props.tab.title} />
|
||||
}
|
||||
if (content.type === 'browser') return <BrowserPane />
|
||||
if (content.type === 'explorer') return <ExplorerPane />
|
||||
if (content.type === 'explorer') return <ExplorerPane row={props.row} index={props.index} />
|
||||
if (content.type === 'scm') return <GitPane />
|
||||
if (content.type === 'tasks') return <JobsPane />
|
||||
if (content.type === 'terminal') return <TerminalPane />
|
||||
@@ -1003,6 +1051,9 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
|
||||
key={t.id}
|
||||
className={'dsh-wt_tab' + (i === active ? ' dsh-wt_tabOn' : '')}
|
||||
title={t.title}
|
||||
draggable
|
||||
onDragStart={(e: any) => { dragTab = { row, index, tabId: t.id }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
|
||||
onDragEnd={() => { dragTab = null; setDropTarget(null) }}
|
||||
onClick={() => splitStore.setActiveTab(row, index, t.id)}
|
||||
>
|
||||
<span className="dsh-wt_tabTitle">{t.title}</span>
|
||||
@@ -1015,7 +1066,7 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<PaneTabBody tab={tabs[active]} />
|
||||
<PaneTabBody tab={tabs[active]} row={row} index={index} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1052,12 +1103,6 @@ function PanePicker(props: { row: PaneRow; index: number }) {
|
||||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
|
||||
<span aria-hidden>📁</span>{T('pane.explorer')}
|
||||
</button>
|
||||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'scm' })}>
|
||||
<span aria-hidden>🔀</span>{T('pane.scm')}
|
||||
</button>
|
||||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'tasks' })}>
|
||||
<span aria-hidden>✅</span>{T('pane.tasks')}
|
||||
</button>
|
||||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
|
||||
<span aria-hidden>▸_</span>{T('pane.terminal')}
|
||||
</button>
|
||||
@@ -1101,6 +1146,14 @@ function SplitWorkspace() {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [snap.active])
|
||||
|
||||
// 标签拖放高亮 → 重渲染
|
||||
const [, setDropTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const fn = () => setDropTick((t) => t + 1)
|
||||
dropTargetListeners.add(fn)
|
||||
return () => { dropTargetListeners.delete(fn) }
|
||||
}, [])
|
||||
|
||||
if (!snap.active || !snap.spec || !snap.geom) return null
|
||||
const g = snap.geom
|
||||
const spec = snap.spec
|
||||
@@ -1135,7 +1188,29 @@ function SplitWorkspace() {
|
||||
const topY = barTop + BAR_H
|
||||
|
||||
const renderPane = (it: { pane: SplitPane; left: number; width: number }, row: PaneRow, index: number, x: number, y: number, h: number) => (
|
||||
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}>
|
||||
<div
|
||||
key={it.pane.id}
|
||||
className="dsh-wt_pane"
|
||||
data-drop-hover={dropTarget && dropTarget.row === row && dropTarget.index === index ? 'true' : undefined}
|
||||
style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}
|
||||
onDragOver={(e: any) => {
|
||||
if (!dragTab) return
|
||||
e.preventDefault()
|
||||
setDropTarget({ row, index })
|
||||
}}
|
||||
onDragLeave={(e: any) => {
|
||||
if (e.currentTarget && !e.currentTarget.contains(e.relatedTarget)) setDropTarget(null)
|
||||
}}
|
||||
onDrop={(e: any) => {
|
||||
e.preventDefault()
|
||||
const s = dragTab
|
||||
dragTab = null
|
||||
setDropTarget(null)
|
||||
if (s && (s.row !== row || s.index !== index)) {
|
||||
splitStore.moveTab(s.row, s.index, s.tabId, row, index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="dsh-wt_paneBar"
|
||||
title={T('split.dragSwap')}
|
||||
@@ -1228,4 +1303,7 @@ function SplitWorkspace() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 调试出口(自动化验证用;必须在 store 定义之后) */
|
||||
try { (window as any).__dshWorktable = { splitStore } } catch {}
|
||||
|
||||
export { SplitWorkspace }
|
||||
|
||||
@@ -75,7 +75,8 @@ export const css = [
|
||||
'.dsh-wt_splitTitle{flex:1;min-width:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.dsh-wt_splitClose{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:12px;cursor:pointer}',
|
||||
'.dsh-wt_splitClose:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
|
||||
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden}',
|
||||
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden;transition:border-color .15s ease,box-shadow .15s ease,transform .15s ease}',
|
||||
'.dsh-wt_pane[data-drop-hover=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 2px rgba(79,142,247,.4),var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4));transform:scale(1.012)}',
|
||||
'.dsh-wt_paneBar{flex:none;display:flex;align-items:center;height:22px;padding:0 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
|
||||
'.dsh-wt_paneTitle{flex:1;min-width:0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.dsh-wt_paneFrame{flex:1;width:100%;border:0;background:#010409;min-height:0}',
|
||||
@@ -154,7 +155,8 @@ export const css = [
|
||||
'.dsh-wt_termHost .xterm{height:100%}',
|
||||
// 窗内标签页
|
||||
'.dsh-wt_tabBar{flex:none;display:flex;align-items:center;gap:3px;padding:3px 5px 0;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));overflow-x:auto}',
|
||||
'.dsh-wt_tab{flex:none;display:flex;align-items:center;gap:5px;max-width:150px;padding:3px 8px;border:1px solid transparent;border-bottom:none;border-radius:6px 6px 0 0;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:10.5px;line-height:16px;cursor:pointer;user-select:none;-webkit-user-select:none}',
|
||||
'.dsh-wt_tab{flex:none;display:flex;align-items:center;gap:5px;max-width:150px;padding:3px 8px;border:1px solid transparent;border-bottom:none;border-radius:6px 6px 0 0;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:10.5px;line-height:16px;cursor:grab;user-select:none;-webkit-user-select:none}',
|
||||
'.dsh-wt_tab:active{cursor:grabbing}',
|
||||
'.dsh-wt_tab:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.04))}',
|
||||
'.dsh-wt_tabOn{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-bg-base,#0b0e14);border-color:var(--dsw-alias-border-l1,#262b36)}',
|
||||
'.dsh-wt_tabTitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
|
||||
+44
-1
@@ -1,8 +1,10 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readdir, realpathSync } from 'node:fs'
|
||||
import { readdirSync, readFile, realpathSync } from 'node:fs'
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { dirname, resolve as pathResolve, sep } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,18 @@ function loadPkg(pkg: string): any | null {
|
||||
dir = pathResolve(dir, '..')
|
||||
}
|
||||
}
|
||||
// 兜底:DSH 标准目录 ~/.dsh/profiles/*/node_modules(宿主按 realpath 加载时前两条链都找不到)
|
||||
try {
|
||||
const profilesDir = pathResolve(homedir(), '.dsh', 'profiles')
|
||||
for (const profile of readdirSync(profilesDir, { withFileTypes: true })) {
|
||||
if (!profile.isDirectory() && !profile.isSymbolicLink()) continue
|
||||
const nm = pathResolve(profilesDir, profile.name, 'node_modules')
|
||||
try {
|
||||
const req = createRequire(pathToFileURL(pathResolve(nm, '__wt_probe__.js')).href)
|
||||
return req(pkg)
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -115,6 +129,7 @@ async function setupTerminal(webServer: any, ctx: any) {
|
||||
try { ptyMod = await import('node-pty') } catch {}
|
||||
if (!wsMod) wsMod = loadPkg('ws')
|
||||
if (!ptyMod) ptyMod = loadPkg('node-pty')
|
||||
ctx.logger?.info?.('[dsh-worktable] term deps: ws=' + (wsMod ? 'ok' : 'MISSING') + ' node-pty=' + (ptyMod ? 'ok' : 'MISSING'))
|
||||
if (!wsMod || !ptyMod) {
|
||||
ctx.logger?.warn('[dsh-worktable] 终端路由未注册:ws/node-pty 均不可用')
|
||||
return
|
||||
@@ -177,6 +192,34 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// 本地文件读取(资源管理器点击 .html 后浏览器标签内打开)
|
||||
webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/file',
|
||||
handler: async (req: any, res: any) => {
|
||||
try {
|
||||
const u = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const p = u.searchParams.get('path') || ''
|
||||
if (!p) { json(res, 400, { error: 'missing path' }); return }
|
||||
const abs = resolve(p)
|
||||
const stat = await import('node:fs/promises').then((m) => m.stat(abs))
|
||||
if (stat.size > 20 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
|
||||
const data = await readFile(abs)
|
||||
const ext = (abs.split('.').pop() || '').toLowerCase()
|
||||
const types: Record<string, string> = {
|
||||
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
|
||||
css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8', md: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
|
||||
svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
|
||||
}
|
||||
res.writeHead(200, { 'content-type': types[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/fs',
|
||||
|
||||
Reference in New Issue
Block a user