feat: 功能窗标签页模型(多标签/关闭回选择器)+ 服务端会话cwd解析(fs/git/term)

This commit is contained in:
Aisland
2026-08-17 12:19:08 +08:00
parent 1d8e969a88
commit e2b43c475b
5 changed files with 223 additions and 43 deletions
+2
View File
@@ -61,6 +61,7 @@ export const zh = {
'pane.gitClean': '工作区干净',
'pane.jobsEmpty': '当前会话暂无后台任务',
'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)',
'pane.closeTab': '关闭标签页',
'split.flip': '切换对话窗位置(左⇄右)',
'split.dragSwap': '拖动标题栏与其他窗交换位置',
'empty': '暂无项目',
@@ -126,6 +127,7 @@ export const en = {
'pane.gitClean': 'Working tree clean',
'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',
'split.flip': 'Flip chat side (left ⇄ right)',
'split.dragSwap': 'Drag the title bar to swap panes',
'empty': 'No projects yet',
+177 -37
View File
@@ -18,7 +18,20 @@ export type SplitContent =
| { kind: 'iframe'; url: string }
| { kind: 'builtin'; type: BuiltinType }
export type SplitPane = { id: string; title: string; min: number; content: SplitContent | null }
/** 一个内容标签页 */
export type PaneTab = { id: string; title: string; content: SplitContent }
export type SplitPane = {
id: string
title: string
min: number
/** 向后兼容:单内容声明(打开时归一化为一个标签页) */
content?: SplitContent | null
/** 标签页模型:内容标签列表(空 = 未指派,显示 6 选 1 选择器) */
tabs?: PaneTab[]
/** 激活的标签下标 */
active?: number
}
export type LayoutSpec = {
id: string
@@ -75,6 +88,9 @@ type SplitState = {
setPaneW(i: number, w: number): void
setTopW(i: number, w: number): void
setPaneContent(row: PaneRow, i: number, content: SplitContent | null): void
openTab(row: PaneRow, i: number, content: SplitContent): void
closeTab(row: PaneRow, i: number, tabId: string): void
setActiveTab(row: PaneRow, i: number, tabId: string): void
swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void
setChatSide(side: 'left' | 'right'): void
persist(): void
@@ -96,6 +112,24 @@ const BUILTIN_ICONS: Record<BuiltinType, string> = {
terminal: '▸_',
}
const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
browser: 'pane.browser',
explorer: 'pane.explorer',
scm: 'pane.scm',
tasks: 'pane.tasks',
terminal: 'pane.terminal',
}
function tabTitleOf(content: SplitContent): string {
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
try {
const u = new URL(content.url)
return u.hostname || content.url
} catch {
return content.url
}
}
/** 分栏 UI 文案提供者(由工作台注入 locale t) */
let uiT: ((key: string) => string) | null = null
export function setSplitT(fn: ((key: string) => string) | null) {
@@ -236,7 +270,18 @@ export const splitStore: SplitState = {
const viewArea = root.children[1] as HTMLElement | undefined
if (!header || !viewArea) return false
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' }
const main = spec.main ?? []
// 向后兼容归一化:单内容声明 → 一个标签页
const normalize = (p: SplitPane): SplitPane => {
if (p.tabs && p.tabs.length > 0) return p
if (p.content) {
return { ...p, content: null, tabs: [{ id: 't1', title: tabTitleOf(p.content), content: p.content }], active: 0 }
}
return { ...p, content: null, tabs: [], active: 0 }
}
if (spec.top) this.spec.top = spec.top.map(normalize)
if (spec.left) this.spec.left = normalize(spec.left)
this.spec.main = (spec.main ?? []).map(normalize)
const main = this.spec.main ?? []
const top = spec.top ?? []
const left = spec.left ?? null
const saved = loadSaved(spec.id)
@@ -441,27 +486,89 @@ export const splitStore: SplitState = {
},
setPaneContent(row, i, content) {
if (content) this.openTab(row, i, content)
},
openTab(row, i, content) {
const spec = this.spec
if (!spec) return
const mutate = (pane: SplitPane): SplitPane => {
const tabs = [...(pane.tabs ?? [])]
const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content }
tabs.push(tab)
return { ...pane, content: null, tabs, active: tabs.length - 1 }
}
if (row === 'left') {
if (!spec.left || i !== 0) return
this.spec = { ...spec, left: { ...spec.left, content } }
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
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 }
}
const top = [...(spec.top ?? [])]
const main = [...spec.main]
const arr = row === 'top' ? top : main
if (!arr[i]) return
arr[i] = { ...arr[i], content }
this.spec = { ...spec, top: top.length > 0 ? top : null, main }
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
closeTab(row, i, tabId) {
const spec = this.spec
if (!spec) return
const mutate = (pane: SplitPane): SplitPane => {
const tabs = (pane.tabs ?? []).filter((t) => t.id !== tabId)
return { ...pane, tabs, active: 0 }
}
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()
},
setActiveTab(row, i, tabId) {
const spec = this.spec
if (!spec) return
const mutate = (pane: SplitPane): SplitPane => {
const idx = (pane.tabs ?? []).findIndex((t) => t.id === tabId)
if (idx < 0) return pane
return { ...pane, active: idx }
}
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.persist()
this.notify()
},
swapPanes(aRow, aI, bRow, bI) {
const spec = this.spec
if (!spec) return
@@ -623,9 +730,12 @@ function ExplorerPane() {
const [loading, setLoading] = useState(false)
const [history, setHistory] = useState<string[]>([])
const load = useCallback((p: string, push: boolean) => {
if (!p) { setError(T('pane.explorerNoCwd')); return }
setLoading(true)
postJson('/api/worktable/fs', { path: p })
postJson('/api/worktable/fs', {
path: p,
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
.then((d) => {
setPath(d.path ?? p)
setEntries(d.entries ?? [])
@@ -679,9 +789,10 @@ function GitPane() {
const [snap, setSnap] = useState<{ isRepo: boolean; branch?: string; entries: any[] } | null>(null)
const [error, setError] = useState('')
const load = useCallback(() => {
const cwd = splitEnv?.getScope()?.cwd ?? ''
if (!cwd) { setError(T('pane.explorerNoCwd')); return }
postJson('/api/worktable/git', { cwd })
postJson('/api/worktable/git', {
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
.then(setSnap)
.catch((e) => setError(String(e)))
}, [])
@@ -754,7 +865,7 @@ function TerminalPane() {
term.open(el)
const scope = splitEnv?.getScope?.()
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = proto + '//' + location.host + '/api/worktable/term?cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24'
const url = proto + '//' + location.host + '/api/worktable/term?sessionId=' + encodeURIComponent(scope?.sessionId ?? '') + '&cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24'
try {
ws = new WebSocket(url)
} catch {
@@ -786,34 +897,63 @@ function TerminalPane() {
return <div ref={hostRef} className="dsh-wt_termHost" />
}
/** 窗内容三态渲染 */
/** 单个标签页的内容渲染 */
function PaneTabBody(props: { tab: PaneTab }) {
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 === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
<span className="dsh-wt_paneWipText">{T('pane.wip')}</span>
</div>
)
}
/** 窗内容:标签页模型(无标签 = 6 选 1 选择器;标签可切换/关闭,关完回到选择器) */
function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
const { pane, row, index } = props
const content = pane.content
if (content && content.kind === 'iframe') {
return <iframe className="dsh-wt_paneFrame" src={content.url} title={pane.title} />
const tabs = pane.tabs ?? []
const active = Math.min(pane.active ?? 0, Math.max(0, tabs.length - 1))
if (tabs.length === 0) {
return <PanePicker row={row} index={index} />
}
if (content && content.kind === 'builtin') {
if (content.type === 'browser') return <BrowserPane />
if (content.type === 'explorer') return <ExplorerPane />
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
<span className="dsh-wt_paneWipText">{T('pane.wip')}</span>
return (
<>
<div className="dsh-wt_tabBar">
{tabs.map((t, i) => (
<span
key={t.id}
className={'dsh-wt_tab' + (i === active ? ' dsh-wt_tabOn' : '')}
title={t.title}
onClick={() => splitStore.setActiveTab(row, index, t.id)}
>
<span className="dsh-wt_tabTitle">{t.title}</span>
<button
type="button"
className="dsh-wt_tabClose"
title={T('pane.closeTab')}
onClick={(e) => { e.stopPropagation(); splitStore.closeTab(row, index, t.id) }}
>✕</button>
</span>
))}
</div>
)
}
return <PanePicker row={row} index={index} />
<PaneTabBody tab={tabs[active]} />
</>
)
}
/** 未指派内容:6 选 1 选择器(better-sidebar 风格 5 项 + 自定义) */
function PanePicker(props: { row: PaneRow; index: number }) {
const [custom, setCustom] = useState(false)
const [url, setUrl] = useState('')
const pick = (content: SplitContent) => splitStore.setPaneContent(props.row, props.index, content)
const pick = (content: SplitContent) => splitStore.openTab(props.row, props.index, content)
const applyCustom = () => {
const u = url.trim()
if (/^(\/|https?:\/\/)/i.test(u)) pick({ kind: 'iframe', url: u })
+8
View File
@@ -152,4 +152,12 @@ export const css = [
'.dsh-wt_jobDot-stopping{color:#9aa4b2}',
'.dsh-wt_termHost{flex:1;min-height:0;padding:6px 2px 2px 8px;background:#010409;overflow:hidden}',
'.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: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}',
'.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))}',
].join('\n')
+20 -6
View File
@@ -13,12 +13,24 @@ import { resolve, sep } from 'node:path'
*/
export const name = 'dsh-worktable'
export const inject = ['webServer']
export const inject = ['webServer', 'sessions']
export const HEALTH_PATH = '/api/worktable/health'
const MAX_ENTRIES = 500
/** 解析会话工作目录:服务端 header.cwd 优先,其次客户端传入 cwd,最后进程 cwd */
function serverCwd(ctx: any, sessionId?: string, clientCwd?: string): string {
if (sessionId) {
try {
const headerCwd = ctx.sessions?.get?.(sessionId)?.header?.cwd
if (typeof headerCwd === 'string' && headerCwd) return headerCwd
} catch {}
}
if (typeof clientCwd === 'string' && clientCwd) return clientCwd
return process.cwd()
}
function json(res: any, status: number, body: unknown) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(body))
@@ -71,7 +83,7 @@ async function gitStatus(cwd: string) {
}
/** 终端 WebSocket 升级路由(node-pty 缺失时不注册,终端窗降级为提示) */
async function setupTerminal(webServer: any) {
async function setupTerminal(webServer: any, ctx: any) {
if (typeof webServer.registerUpgrade !== 'function') return
let wsMod: any = null
let ptyMod: any = null
@@ -91,7 +103,7 @@ async function setupTerminal(webServer: any) {
handler: (req: any, socket: any, head: any) => {
wss.handleUpgrade(req, socket, head, (ws: any) => {
const u = new URL(req.url ?? '/', 'http://dsh.internal')
const cwd = u.searchParams.get('cwd') || process.cwd()
const cwd = serverCwd(ctx, u.searchParams.get('sessionId') || undefined, u.searchParams.get('cwd') || undefined)
const cols = clampDim(Number(u.searchParams.get('cols')), 80)
const rows = clampDim(Number(u.searchParams.get('rows')), 24)
let term: any = null
@@ -142,7 +154,9 @@ export function apply(ctx: Context) {
handler: async (req: any, res: any) => {
try {
const body = await readJsonBody(req)
const path = typeof body.path === 'string' && body.path ? body.path : process.cwd()
const path = typeof body.path === 'string' && body.path
? body.path
: serverCwd(ctx, body.sessionId, body.cwd)
json(res, 200, await listDirectory(path))
} catch (err) {
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
@@ -155,10 +169,10 @@ export function apply(ctx: Context) {
path: '/api/worktable/git',
handler: async (req: any, res: any) => {
const body = await readJsonBody(req)
const cwd = typeof body.cwd === 'string' && body.cwd ? body.cwd : process.cwd()
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
json(res, 200, await gitStatus(cwd))
},
})
setupTerminal(webServer).catch(() => {})
setupTerminal(webServer, ctx).catch(() => {})
}
@@ -373,3 +373,19 @@
- 回归验证:headless Chrome 重跑——wtSection/railBox 渲染正常、ERRORS_COUNT=0;
用户刷新即恢复(客户端实时下发,无需重启)。
- 工具沉淀:04_test/headless-diag.cjs(headless Chrome + CDP 页面诊断,可复用)。
## 补记(功能窗标签页模型 + 数据层修复)
- 用户反馈:① 窗口内容一旦选定就无法更换/回退;② 资源管理器、后台任务没有内容;
③ 交互形态与 better-sidebar 差异大、不可用。
- 实现:
① 标签页模型(split.tsx):SplitPane 增加 tabs[]/active(兼容旧 content 字段,打开时
归一化为单标签);openTab/closeTab/setActiveTab 三个变更方法;窗内标签栏(多标签可切换、
✕ 关闭;关完回到 6 选 1 选择器);标签标题 = 内置类型名或 URL 主机名;变更经
onSpecMutated 回写持久化。
② 数据层(服务端):inject 增加 'sessions'(better-sidebar 同款),serverCwd 解析 =
header.cwd → 客户端 cwd → process.cwd();fs/git/term 三路由全部走该解析;
客户端请求带 sessionId。解决「资源管理器没有内容」(此前仅依赖客户端列表 cwd,缺失时空白)。
③ 任务窗数据源不变(jobsBySession,无后台任务时显示空态文案)。
- 验证:构建 + node --check 通过;headless Chrome 回归零报错、区块正常渲染。
- 注意:服务端 cwd 解析需**重启 dsh web 后生效**;标签页模型为客户端改动、刷新即生效。