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.gitClean': '工作区干净',
'pane.jobsEmpty': '当前会话暂无后台任务', 'pane.jobsEmpty': '当前会话暂无后台任务',
'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)', 'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)',
'pane.closeTab': '关闭标签页',
'split.flip': '切换对话窗位置(左⇄右)', 'split.flip': '切换对话窗位置(左⇄右)',
'split.dragSwap': '拖动标题栏与其他窗交换位置', 'split.dragSwap': '拖动标题栏与其他窗交换位置',
'empty': '暂无项目', 'empty': '暂无项目',
@@ -126,6 +127,7 @@ export const en = {
'pane.gitClean': 'Working tree clean', 'pane.gitClean': 'Working tree clean',
'pane.jobsEmpty': 'No background jobs in the current session', 'pane.jobsEmpty': 'No background jobs in the current session',
'pane.termFail': 'Terminal unavailable (host missing node-pty/ws, or connection failed)', 'pane.termFail': 'Terminal unavailable (host missing node-pty/ws, or connection failed)',
'pane.closeTab': 'Close tab',
'split.flip': 'Flip chat side (left ⇄ right)', 'split.flip': 'Flip chat side (left ⇄ right)',
'split.dragSwap': 'Drag the title bar to swap panes', 'split.dragSwap': 'Drag the title bar to swap panes',
'empty': 'No projects yet', 'empty': 'No projects yet',
+177 -37
View File
@@ -18,7 +18,20 @@ export type SplitContent =
| { kind: 'iframe'; url: string } | { kind: 'iframe'; url: string }
| { kind: 'builtin'; type: BuiltinType } | { 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 = { export type LayoutSpec = {
id: string id: string
@@ -75,6 +88,9 @@ type SplitState = {
setPaneW(i: number, w: number): void setPaneW(i: number, w: number): void
setTopW(i: number, w: number): void setTopW(i: number, w: number): void
setPaneContent(row: PaneRow, i: number, content: SplitContent | null): 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 swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void
setChatSide(side: 'left' | 'right'): void setChatSide(side: 'left' | 'right'): void
persist(): void persist(): void
@@ -96,6 +112,24 @@ const BUILTIN_ICONS: Record<BuiltinType, string> = {
terminal: '▸_', 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) */ /** 分栏 UI 文案提供者(由工作台注入 locale t) */
let uiT: ((key: string) => string) | null = null let uiT: ((key: string) => string) | null = null
export function setSplitT(fn: ((key: string) => string) | 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 const viewArea = root.children[1] as HTMLElement | undefined
if (!header || !viewArea) return false if (!header || !viewArea) return false
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' } 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 top = spec.top ?? []
const left = spec.left ?? null const left = spec.left ?? null
const saved = loadSaved(spec.id) const saved = loadSaved(spec.id)
@@ -441,27 +486,89 @@ export const splitStore: SplitState = {
}, },
setPaneContent(row, i, content) { setPaneContent(row, i, content) {
if (content) this.openTab(row, i, content)
},
openTab(row, i, content) {
const spec = this.spec const spec = this.spec
if (!spec) return 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 (row === 'left') {
if (!spec.left || i !== 0) return if (!spec.left || i !== 0) return
this.spec = { ...spec, left: { ...spec.left, content } } this.spec = { ...spec, left: mutate(spec.left) }
this.onSpecMutated?.(this.spec) } else if (row === 'top') {
this.persist() const top = [...(spec.top ?? [])]
this.notify() if (!top[i]) return
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.onSpecMutated?.(this.spec)
this.persist() this.persist()
this.notify() 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) { swapPanes(aRow, aI, bRow, bI) {
const spec = this.spec const spec = this.spec
if (!spec) return if (!spec) return
@@ -623,9 +730,12 @@ function ExplorerPane() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [history, setHistory] = useState<string[]>([]) const [history, setHistory] = useState<string[]>([])
const load = useCallback((p: string, push: boolean) => { const load = useCallback((p: string, push: boolean) => {
if (!p) { setError(T('pane.explorerNoCwd')); return }
setLoading(true) setLoading(true)
postJson('/api/worktable/fs', { path: p }) postJson('/api/worktable/fs', {
path: p,
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
.then((d) => { .then((d) => {
setPath(d.path ?? p) setPath(d.path ?? p)
setEntries(d.entries ?? []) setEntries(d.entries ?? [])
@@ -679,9 +789,10 @@ function GitPane() {
const [snap, setSnap] = useState<{ isRepo: boolean; branch?: string; entries: any[] } | null>(null) const [snap, setSnap] = useState<{ isRepo: boolean; branch?: string; entries: any[] } | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const load = useCallback(() => { const load = useCallback(() => {
const cwd = splitEnv?.getScope()?.cwd ?? '' postJson('/api/worktable/git', {
if (!cwd) { setError(T('pane.explorerNoCwd')); return } sessionId: splitEnv?.getScope()?.sessionId ?? '',
postJson('/api/worktable/git', { cwd }) cwd: splitEnv?.getScope()?.cwd ?? '',
})
.then(setSnap) .then(setSnap)
.catch((e) => setError(String(e))) .catch((e) => setError(String(e)))
}, []) }, [])
@@ -754,7 +865,7 @@ function TerminalPane() {
term.open(el) term.open(el)
const scope = splitEnv?.getScope?.() const scope = splitEnv?.getScope?.()
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' 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 { try {
ws = new WebSocket(url) ws = new WebSocket(url)
} catch { } catch {
@@ -786,34 +897,63 @@ function TerminalPane() {
return <div ref={hostRef} className="dsh-wt_termHost" /> 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 }) { function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
const { pane, row, index } = props const { pane, row, index } = props
const content = pane.content const tabs = pane.tabs ?? []
if (content && content.kind === 'iframe') { const active = Math.min(pane.active ?? 0, Math.max(0, tabs.length - 1))
return <iframe className="dsh-wt_paneFrame" src={content.url} title={pane.title} /> if (tabs.length === 0) {
return <PanePicker row={row} index={index} />
} }
if (content && content.kind === 'builtin') { return (
if (content.type === 'browser') return <BrowserPane /> <>
if (content.type === 'explorer') return <ExplorerPane /> <div className="dsh-wt_tabBar">
if (content.type === 'scm') return <GitPane /> {tabs.map((t, i) => (
if (content.type === 'tasks') return <JobsPane /> <span
if (content.type === 'terminal') return <TerminalPane /> key={t.id}
return ( className={'dsh-wt_tab' + (i === active ? ' dsh-wt_tabOn' : '')}
<div className="dsh-wt_paneWip"> title={t.title}
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span> onClick={() => splitStore.setActiveTab(row, index, t.id)}
<span className="dsh-wt_paneWipText">{T('pane.wip')}</span> >
<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> </div>
) <PaneTabBody tab={tabs[active]} />
} </>
return <PanePicker row={row} index={index} /> )
} }
/** 未指派内容:6 选 1 选择器(better-sidebar 风格 5 项 + 自定义) */ /** 未指派内容:6 选 1 选择器(better-sidebar 风格 5 项 + 自定义) */
function PanePicker(props: { row: PaneRow; index: number }) { function PanePicker(props: { row: PaneRow; index: number }) {
const [custom, setCustom] = useState(false) const [custom, setCustom] = useState(false)
const [url, setUrl] = useState('') 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 applyCustom = () => {
const u = url.trim() const u = url.trim()
if (/^(\/|https?:\/\/)/i.test(u)) pick({ kind: 'iframe', url: u }) 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_jobDot-stopping{color:#9aa4b2}',
'.dsh-wt_termHost{flex:1;min-height:0;padding:6px 2px 2px 8px;background:#010409;overflow:hidden}', '.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_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') ].join('\n')
+20 -6
View File
@@ -13,12 +13,24 @@ import { resolve, sep } from 'node:path'
*/ */
export const name = 'dsh-worktable' export const name = 'dsh-worktable'
export const inject = ['webServer'] export const inject = ['webServer', 'sessions']
export const HEALTH_PATH = '/api/worktable/health' export const HEALTH_PATH = '/api/worktable/health'
const MAX_ENTRIES = 500 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) { function json(res: any, status: number, body: unknown) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(body)) res.end(JSON.stringify(body))
@@ -71,7 +83,7 @@ async function gitStatus(cwd: string) {
} }
/** 终端 WebSocket 升级路由(node-pty 缺失时不注册,终端窗降级为提示) */ /** 终端 WebSocket 升级路由(node-pty 缺失时不注册,终端窗降级为提示) */
async function setupTerminal(webServer: any) { async function setupTerminal(webServer: any, ctx: any) {
if (typeof webServer.registerUpgrade !== 'function') return if (typeof webServer.registerUpgrade !== 'function') return
let wsMod: any = null let wsMod: any = null
let ptyMod: any = null let ptyMod: any = null
@@ -91,7 +103,7 @@ async function setupTerminal(webServer: any) {
handler: (req: any, socket: any, head: any) => { handler: (req: any, socket: any, head: any) => {
wss.handleUpgrade(req, socket, head, (ws: any) => { wss.handleUpgrade(req, socket, head, (ws: any) => {
const u = new URL(req.url ?? '/', 'http://dsh.internal') 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 cols = clampDim(Number(u.searchParams.get('cols')), 80)
const rows = clampDim(Number(u.searchParams.get('rows')), 24) const rows = clampDim(Number(u.searchParams.get('rows')), 24)
let term: any = null let term: any = null
@@ -142,7 +154,9 @@ export function apply(ctx: Context) {
handler: async (req: any, res: any) => { handler: async (req: any, res: any) => {
try { try {
const body = await readJsonBody(req) 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)) json(res, 200, await listDirectory(path))
} catch (err) { } catch (err) {
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) }) json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
@@ -155,10 +169,10 @@ export function apply(ctx: Context) {
path: '/api/worktable/git', path: '/api/worktable/git',
handler: async (req: any, res: any) => { handler: async (req: any, res: any) => {
const body = await readJsonBody(req) 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)) json(res, 200, await gitStatus(cwd))
}, },
}) })
setupTerminal(webServer).catch(() => {}) setupTerminal(webServer, ctx).catch(() => {})
} }
@@ -373,3 +373,19 @@
- 回归验证:headless Chrome 重跑——wtSection/railBox 渲染正常、ERRORS_COUNT=0; - 回归验证:headless Chrome 重跑——wtSection/railBox 渲染正常、ERRORS_COUNT=0;
用户刷新即恢复(客户端实时下发,无需重启)。 用户刷新即恢复(客户端实时下发,无需重启)。
- 工具沉淀:04_test/headless-diag.cjs(headless Chrome + CDP 页面诊断,可复用)。 - 工具沉淀: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 后生效**;标签页模型为客户端改动、刷新即生效。