feat: 功能窗标签页模型(多标签/关闭回选择器)+ 服务端会话cwd解析(fs/git/term)
This commit is contained in:
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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 后生效**;标签页模型为客户端改动、刷新即生效。
|
||||
|
||||
Reference in New Issue
Block a user