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