v0.2.3: pane collapse, 1px dividers, flush pane edges

This commit is contained in:
Aisland
2026-08-29 21:39:19 +08:00
parent de4c5e2bf8
commit 0a9ff9d34e
8 changed files with 209 additions and 95 deletions
+4
View File
@@ -153,6 +153,8 @@ export const zh = {
'pane.jobsEmpty': '当前会话暂无后台任务',
'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)',
'pane.closeTab': '关闭标签页',
'pane.collapse': '折叠窗格',
'pane.expand': '展开窗格',
'pane.jobsTitle': '后台任务',
'pane.subagents': '子代理',
'pane.subagentsEmpty': '当前会话暂无子代理',
@@ -329,6 +331,8 @@ 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.collapse': 'Collapse pane',
'pane.expand': 'Expand pane',
'pane.jobsTitle': 'Background jobs',
'pane.subagents': 'Subagents',
'pane.subagentsEmpty': 'No subagents in the current session',
+62 -15
View File
@@ -44,6 +44,8 @@ export type SplitPane = {
tabs?: PaneTab[]
/** 激活的标签下标 */
active?: number
/** 头部折叠:true = 隐藏窗格标题与标签栏(内容占满;随 spec 持久化) */
collapsed?: boolean
}
export type LayoutSpec = {
@@ -110,6 +112,7 @@ type SplitState = {
lockPane(row: PaneRow, i: number, content: SplitContent): void
closeTab(row: PaneRow, i: number, tabId: string): void
setActiveTab(row: PaneRow, i: number, tabId: string): void
toggleCollapsed(row: PaneRow, i: number): 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
@@ -119,7 +122,8 @@ type SplitState = {
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const DIVIDER = 4
const GAP = 1 // 窗格之间真实布局间隙:1px(分隔细线即间隙,底色贴线)
const DIVIDER = 4 // 分隔条热区宽度:透明覆盖层,细线画在热区中间与两侧内容贴齐
const BAR_H = 26
const PERSIST_KEY = 'dsh.worktable.split.v2'
@@ -414,7 +418,7 @@ export const splitStore: SplitState = {
}
if (!hasPaneWs) {
const contentW = Math.max(0, colW0 - this.chatW)
const avail = Math.max(main.length * 120, contentW - Math.max(0, main.length - 1) * DIVIDER)
const avail = Math.max(main.length * 120, contentW - Math.max(0, main.length - 1) * GAP)
const share = Math.round(avail / main.length)
this.paneWs = main.map((p) => Math.max(p.min, share))
}
@@ -422,7 +426,7 @@ export const splitStore: SplitState = {
// chatFull 时顶行只占内容侧(扣除聊天列宽)
const chatW0 = spec.chatFullHeight === true ? this.chatW : 0
const rowW = Math.max(0, colW0 - chatW0 - (left ? this.leftW : 0))
const avail = Math.max(top.length * 120, rowW - Math.max(0, top.length - 1) * DIVIDER)
const avail = Math.max(top.length * 120, rowW - Math.max(0, top.length - 1) * GAP)
const share = Math.round(avail / top.length)
this.topWs = top.map((p) => Math.max(p.min, share))
}
@@ -545,7 +549,7 @@ export const splitStore: SplitState = {
if (!g || !spec) return
const colW = g.right - g.left
const main = spec.main ?? []
const minContent = main.reduce((a, p) => a + p.min, 0) + Math.max(0, main.length - 1) * DIVIDER
const minContent = main.reduce((a, p) => a + p.min, 0) + Math.max(0, main.length - 1) * GAP
const hi = Math.max(spec.chatWidth.min, colW - minContent)
this.chatW = clamp(Math.round(w), spec.chatWidth.min, hi)
this.applyMargin()
@@ -590,7 +594,7 @@ export const splitStore: SplitState = {
const contentW = Math.max(0, colW - chatW)
const othersMin = main.reduce((a, p, k) => a + (k === i ? 0 : p.min), 0)
const lo = main[i].min
const hi = Math.max(lo, contentW - othersMin - Math.max(0, main.length - 1) * DIVIDER)
const hi = Math.max(lo, contentW - othersMin - Math.max(0, main.length - 1) * GAP)
const next = this.paneWs.slice()
next[i] = clamp(Math.round(w), lo, hi)
this.paneWs = next
@@ -607,7 +611,7 @@ export const splitStore: SplitState = {
const colW = g.right - g.left
const othersMin = top.reduce((a, p, k) => a + (k === i ? 0 : p.min), 0)
const lo = top[i].min
const hi = Math.max(lo, colW - othersMin - Math.max(0, top.length - 1) * DIVIDER)
const hi = Math.max(lo, colW - othersMin - Math.max(0, top.length - 1) * GAP)
const next = this.topWs.slice()
next[i] = clamp(Math.round(w), lo, hi)
this.topWs = next
@@ -778,6 +782,29 @@ export const splitStore: SplitState = {
this.notify()
},
toggleCollapsed(row, i) {
const spec = this.spec
if (!spec) return
const mutate = (pane: SplitPane): SplitPane => ({ ...pane, collapsed: !pane.collapsed })
if (row === 'left') {
if (!spec.left || i !== 0) return
this.spec = { ...spec, left: mutate(spec.left) }
} else if (row === 'top') {
const top = [...(spec.top ?? [])]
if (!top[i]) return
top[i] = mutate(top[i])
this.spec = { ...spec, top }
} else {
const main = [...spec.main]
if (!main[i]) return
main[i] = mutate(main[i])
this.spec = { ...spec, main }
}
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
swapPanes(aRow, aI, bRow, bI) {
const spec = this.spec
if (!spec) return
@@ -871,13 +898,13 @@ if (typeof document !== 'undefined' && document.body) {
/** 分配各窗宽度(最后一个拿余量) */
function allocate(panes: SplitPane[], ws: number[], total: number) {
const out: { pane: SplitPane; left: number; width: number }[] = []
const gapTotal = Math.max(0, panes.length - 1) * DIVIDER
const gapTotal = Math.max(0, panes.length - 1) * GAP
const avail = Math.max(0, total - gapTotal)
let x = 0
panes.forEach((p, i) => {
const w = i === panes.length - 1 ? Math.max(0, avail - x) : ws[i]
out.push({ pane: p, left: x, width: w })
x += w + DIVIDER
x += w + GAP
})
return out
}
@@ -898,10 +925,10 @@ function makeDividerHandler(kind: 'left' | 'chat' | 'top' | 'pane' | 'topPane',
} else if (kind === 'top') {
splitStore.setTopH(ev.clientY - g.top - BAR_H)
} else if (kind === 'pane' && index != null) {
const prefix = splitStore.paneWs.slice(0, index).reduce((a, b) => a + b, 0) + index * DIVIDER
const prefix = splitStore.paneWs.slice(0, index).reduce((a, b) => a + b, 0) + index * GAP
splitStore.setPaneW(index, ev.clientX - (g.left + prefix))
} else if (kind === 'topPane' && index != null) {
const prefix = splitStore.topWs.slice(0, index).reduce((a, b) => a + b, 0) + index * DIVIDER
const prefix = splitStore.topWs.slice(0, index).reduce((a, b) => a + b, 0) + index * GAP
splitStore.setTopW(index, ev.clientX - (g.left + prefix))
}
}
@@ -1769,7 +1796,7 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
const singleConsole = tabs.length === 1 && tabs[0].content?.kind === 'builtin' && tabs[0].content.type === 'console'
return (
<>
{!singleConsole && (
{!singleConsole && !pane.collapsed && (
<div className="dsh-wt_tabBar">
{tabs.map((t, i) => {
// 控制室标签不可关闭:关掉后窗格变选择器,用户回不到控制室(不可逆操作)
@@ -1889,7 +1916,9 @@ function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: num
const mainH = paneBottom - bodyTop
const topY = barTop + BAR_H
const renderPane = (it: { pane: SplitPane; left: number; width: number }, row: PaneRow, index: number, x: number, y: number, h: number) => (
const renderPane = (it: { pane: SplitPane; left: number; width: number }, row: PaneRow, index: number, x: number, y: number, h: number) => {
const singleConsole = (it.pane.tabs ?? []).length === 1 && it.pane.tabs![0].content?.kind === 'builtin' && it.pane.tabs![0].content.type === 'console'
return (
<div
key={it.pane.id}
className="dsh-wt_pane"
@@ -1913,6 +1942,7 @@ function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: num
}
}}
>
{!it.pane.collapsed && (
<div
className="dsh-wt_paneBar"
title={T('split.dragSwap')}
@@ -1929,9 +1959,26 @@ function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: num
>
<span className="dsh-wt_paneTitle">{it.pane.title}</span>
</div>
)}
<PaneBody pane={it.pane} row={row} index={index} />
{!singleConsole && (
<button
type="button"
className={'dsh-wt_collapseBtn' + (it.pane.collapsed ? ' dsh-wt_collapseBtnCollapsed' : '')}
title={it.pane.collapsed ? T('pane.expand') : T('pane.collapse')}
aria-label={it.pane.collapsed ? T('pane.expand') : T('pane.collapse')}
onClick={() => splitStore.toggleCollapsed(row, index)}
>
{it.pane.collapsed ? (
<svg viewBox="0 0 16 16" aria-hidden><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
) : (
<svg viewBox="0 0 16 16" aria-hidden><path d="M4 10l4-4 4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
)}
</button>
)}
</div>
)
)
}
return (
<>
@@ -1971,7 +2018,7 @@ function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: num
className="dsh-wt_splitDivider"
role="separator"
title="拖动调整宽度"
style={{ position: 'fixed', left: topRowX + it.left + it.width, top: topY, width: DIVIDER, height: topH, zIndex: 72 }}
style={{ position: 'fixed', left: topRowX + it.left + it.width - DIVIDER / 2, top: topY, width: DIVIDER, height: topH, zIndex: 72 }}
onPointerDown={makeDividerHandler('topPane', i)}
/>
))}
@@ -1982,7 +2029,7 @@ function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: num
className="dsh-wt_splitDivider"
role="separator"
title="拖动调整宽度"
style={{ position: 'fixed', left: contentX + it.left + it.width, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
style={{ position: 'fixed', left: contentX + it.left + it.width - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
onPointerDown={makeDividerHandler('pane', i)}
/>
))}
+15 -5
View File
@@ -89,7 +89,9 @@ export const css = xtermCss + '\n' + [
'.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;transition:border-color .15s ease,box-shadow .15s ease,transform .15s ease}',
// 窗格容器:无圆角、无描边,直角贴齐分隔细线;底色保持不透明(标题栏/标签栏是 2% 浅色底,
// 窗格若透明会露出宿主垫在下面的文字)
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:none;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden;transition:border-color .15s ease,box-shadow .15s ease,transform .15s ease}',
// 文件预览:文本/MD 滚动容器 + MD 排版 + 图片居中
'.dsh-wt_fileView{flex:1;min-height:0;overflow:auto;padding:12px 14px;background:var(--dsw-alias-bg-base,#0b0e14);color:var(--dsw-alias-label-primary,#e6e8eb);font-size:12.5px;line-height:1.7}',
'.dsh-wt_txt{margin:0;font-family:var(--dsw-font-mono,Consolas,Menlo,monospace);font-size:12px;line-height:1.65;white-space:pre-wrap;word-break:break-word}',
@@ -138,16 +140,24 @@ export const css = xtermCss + '\n' + [
'.dsh-wt_imgView{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:auto;padding:10px;background:var(--dsw-alias-bg-base,#0b0e14)}',
'.dsh-wt_imgView img{max-width:100%;max-height:100%;object-fit:contain}',
'.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_pane[data-drop-hover=true]{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_paneBar{padding-right:26px}',
'.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}',
// 窗格折叠按钮:常态只是一个折角小标签(无底色无描边),hover 才显示按钮范围(底+描边)
'.dsh-wt_collapseBtn{position:absolute;top:2px;right:4px;z-index:2;width:18px;height:18px;margin:0;padding:0;display:flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);cursor:pointer}',
'.dsh-wt_collapseBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l2,rgba(255,255,255,.09));border-color:var(--dsw-alias-border-l1,#262b36)}',
'.dsh-wt_collapseBtn svg{width:12px;height:12px;display:block}',
'.dsh-wt_collapseBtnCollapsed{color:var(--dsw-alias-label-secondary,#9aa4b2)}',
'.dsh-wt_paneFrame{flex:1;width:100%;border:0;background:#010409;min-height:0}',
// iframe 内容标签:容器 + 右上角常驻刷新按钮
'.dsh-wt_tabRefresh{flex:none;width:14px;height:14px;padding:0;border:none;border-radius:3px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:10px;line-height:1;cursor:pointer}',
'.dsh-wt_tabRefresh:hover{color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08))}',
'.dsh-wt_splitDivider{background:var(--dsw-alias-border-l2,#3a4150);cursor:col-resize;touch-action:none;box-sizing:border-box}',
'.dsh-wt_splitDividerH{cursor:row-resize}',
'.dsh-wt_splitDivider:hover,.dsh-wt_splitDividerH:hover{background:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
// 分隔条:热区保持 4px 宽(好拖),视觉上只在中间画 1px 细实线;hover 时细线变主题蓝高亮 + 微光
'.dsh-wt_splitDivider{background:linear-gradient(to right,transparent 2px,var(--dsw-alias-border-l2,#3a4150) 2px,var(--dsw-alias-border-l2,#3a4150) 3px,transparent 3px);cursor:col-resize;touch-action:none;box-sizing:border-box}',
'.dsh-wt_splitDividerH{cursor:row-resize;background:linear-gradient(to bottom,transparent 2px,var(--dsw-alias-border-l2,#3a4150) 2px,var(--dsw-alias-border-l2,#3a4150) 3px,transparent 3px)}',
'.dsh-wt_splitDivider:hover{background:linear-gradient(to right,transparent 2px,var(--dsw-alias-state-accent-primary,#4f8ef7) 2px,var(--dsw-alias-state-accent-primary,#4f8ef7) 3px,transparent 3px);box-shadow:0 0 6px rgba(79,142,247,.55)}',
'.dsh-wt_splitDividerH:hover{background:linear-gradient(to bottom,transparent 2px,var(--dsw-alias-state-accent-primary,#4f8ef7) 2px,var(--dsw-alias-state-accent-primary,#4f8ef7) 3px,transparent 3px);box-shadow:0 0 6px rgba(79,142,247,.55)}',
// 新建工作区(拓扑预设)
'.dsh-wt_presets{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:2px 8px 6px}',
'.dsh-wt_preset{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 2px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:10px;line-height:13px;cursor:pointer}',