feat: 内容窗收敛4项 + 终端loadPkg修复(E2E验证) + 文件路由 + 标签跨窗拖动吸附 + 自动化测试

This commit is contained in:
Aisland
2026-08-17 12:49:27 +08:00
parent eabd367e48
commit dcb75d78cc
7 changed files with 329 additions and 17 deletions
+92 -14
View File
@@ -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 }
+4 -2
View File
@@ -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}',