Files
dsh-worktable/01_content/src/client/split.tsx
T

2065 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js/lib/core'
import hljsTypescript from 'highlight.js/lib/languages/typescript'
import hljsJavascript from 'highlight.js/lib/languages/javascript'
import hljsCss from 'highlight.js/lib/languages/css'
import hljsJson from 'highlight.js/lib/languages/json'
hljs.registerLanguage('typescript', hljsTypescript)
hljs.registerLanguage('javascript', hljsJavascript)
hljs.registerLanguage('css', hljsCss)
hljs.registerLanguage('json', hljsJson)
/**
* dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。
* 布局模型:标题栏 + 顶部通栏行(可选) + 主行内容窗 + 聊天窗(官方会话视图区整体,
* 贴右或贴左,由 chatSide 决定;marginLeft/marginRight + marginTop 组合挤法)。
* 内容三态:null(未指派 → 6 选 1 选择器)/ iframe / builtin(浏览器/资源管理器/SCM/任务/终端)。
* 窗位调整:标题栏拖拽换位(同行或跨行);工具栏 ⇄ 切换聊天窗左右。
* 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v2;
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
*/
export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom' | 'console'
export type SplitContent =
| { kind: 'iframe'; url: string; title?: string }
| { kind: 'builtin'; type: BuiltinType }
| { kind: 'file'; path: string }
/** 一个内容标签页 */
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
title: string
top: SplitPane[] | null
main: SplitPane[]
/** 左列整高内容窗(可选;存在时右侧列 = top 行 + 底部聊天,chatSide 固定 right) */
left?: SplitPane | null
leftWidth?: { default: number; min: number; max: number }
chatWidth: { default: number; min: number; max: number }
topHeight?: { default: number; min: number; max: number }
/** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */
chatSide?: 'left' | 'right'
/** 布局条目图标(emoji;工作台侧栏展示,点击可换) */
icon?: string
/** 聊天窗通高(整列):为 true 时聊天占整条右/左列,内容区(含 top 行)全部排在其另一侧 */
chatFullHeight?: boolean
/** 顶行首次打开时高度占可用高度比例(0~1;0.5 = 上下等分);拖动后由存档值覆盖 */
topHeightRatio?: number
}
type Geom = { left: number; top: number; right: number; bottom: number }
type PaneRow = 'left' | 'top' | 'main'
type SplitState = {
active: boolean
spec: LayoutSpec | null
geom: Geom | null
chatW: number
topH: number
leftW: number
paneWs: number[]
topWs: number[]
leftWs: number[]
root: HTMLElement | null
header: HTMLElement | null
viewArea: HTMLElement | null
savedMarginLeft: string
savedMarginRight: string
savedMarginTop: string
observer: ResizeObserver | null
fallback: MutationObserver | null
yieldObserver: MutationObserver | null
lastMarginLeft: string
lastMarginRight: string
lastMarginTop: string
onSpecMutated: ((spec: LayoutSpec) => void) | null
listeners: Set<() => void>
open(spec: LayoutSpec): boolean
close(): void
syncAnchor(): void
refreshGeom(): void
applyMargin(): void
setChatW(w: number): void
setTopH(h: number): void
setLeftW(w: number): void
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
/** 锁定窗格:清空原有标签,把内容作为该窗唯一的固定标签(挂载产物的「锁死」语义) */
lockPane(row: PaneRow, i: number, content: SplitContent): void
closeTab(row: PaneRow, i: number, tabId: string): void
setActiveTab(row: PaneRow, i: number, tabId: string): 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
persist(): void
subscribe(fn: () => void): () => void
notify(): void
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const DIVIDER = 6
const BAR_H = 26
const PERSIST_KEY = 'dsh.worktable.split.v2'
/** 内置内容窗图标 */
const BUILTIN_ICONS: Record<BuiltinType, string> = {
browser: '🌐',
anim: '🎬',
explorer: '📁',
scm: '🔀',
tasks: '✅',
terminal: '▸_',
custom: '✨',
console: '🖥️',
}
const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
browser: 'pane.browser',
anim: 'pane.anim',
explorer: 'pane.explorer',
scm: 'pane.scm',
tasks: 'pane.tasks',
terminal: 'pane.terminal',
custom: 'pane.custom',
console: 'pane.console',
}
function tabTitleOf(content: SplitContent): string {
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
if (content.kind === 'file') return basenameOf(content.path)
if (content.kind === 'iframe' && content.title) return content.title
try {
const u = new URL(content.url)
return u.hostname || content.url
} catch {
return content.url
}
}
/** 取路径最后一段作为标签标题 */
function basenameOf(p: string): string {
const parts = String(p).replace(/[\\/]+$/, '').split(/[\\/]/)
return parts[parts.length - 1] || String(p)
}
/** 内容同一性(openTab 去重:同窗内同内容只保留一个标签,再次打开切过去) */
function sameContent(a: SplitContent, b: SplitContent): boolean {
if (a.kind === 'iframe' && b.kind === 'iframe') return a.url === b.url
if (a.kind === 'file' && b.kind === 'file') return a.path === b.path
if (a.kind === 'builtin' && b.kind === 'builtin') return a.type === b.type
return false
}
/** 分栏 UI 文案提供者(由工作台注入 locale t) */
let uiT: ((key: string, params?: Record<string, string>) => string) | null = null
export function setSplitT(fn: ((key: string, params?: Record<string, string>) => string) | null) {
uiT = fn
}
const T = (key: string, params?: Record<string, string>): string => (uiT ? uiT(key, params) : key)
/** 工作区环境(由工作台注入:当前会话作用域与后台任务列表) */
export type SplitScope = { sessionId: string; cwd: string }
export type SplitJob = {
id: string
kind: string
label: string
status: string
detail?: string
startedAt: number
finishedAt?: number
}
/** 控制室卡片数据(index.tsx 组装;纯读镜像,零轮询零 Token) */
export type ConsoleCardData = {
id: string
name: string
icon: string
/** 三态 + 空闲:need(待决黄) > done(完成绿) > busy(工作中蓝) > idle */
status: 'idle' | 'busy' | 'need' | 'done'
/** 正在运行时的本轮已用毫秒;非运行态 null */
runtimeMs: number | null
/** 子代理数量 */
kids: number
/** 最近一条消息预览(单行文本,可能为空) */
preview: string
/** 是否绑定对话 */
bound: boolean
/** 是否为「工作台」自己(卡片点击无操作) */
self: boolean
/** 完成/待决且未被确认:卡片发对应色光(点击确认后熄灭) */
glow: boolean
}
type SplitEnv = {
getScope: () => SplitScope | null
getJobs: () => SplitJob[]
getSubagents: () => any[]
/** 自定义窗口:项目列表 + 会话列表 + 当前项目 + 新建会话 / 发送到已有会话 */
custom?: {
getProjects: () => { id: string; name: string }[]
currentProjectId: () => string | null
getSessions: () => Promise<{ groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]; current: string }>
submit: (projectId: string, projectName: string, requirement: string) => Promise<void>
sendToSession: (sessionId: string, projectName: string, requirement: string) => Promise<void>
}
/** 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话 + 主题 */
console?: {
subscribe: (fn: () => void) => () => void
getCards: () => ConsoleCardData[]
onOpen: (id: string) => void
onJump: (id: string) => void
/** 点发光卡片:确认(熄灭光)再打开 */
onAck: (id: string) => void
/** 冷会话消息预热(打开控制室时拉最近消息) */
refreshPreviews: () => void
getTheme: () => 'dark' | 'light' | 'system'
setTheme: (th: 'dark' | 'light' | 'system') => void
}
}
let splitEnv: SplitEnv | null = null
export function setSplitEnv(env: SplitEnv | null) {
splitEnv = env
}
async function postJson(url: string, body: unknown): Promise<any> {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.json()
}
function parentPathOf(p: string): string {
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
if (idx <= 0) return p
let parent = p.slice(0, idx)
if (/^[A-Za-z]:$/.test(parent)) parent += '\\'
return parent
}
/** 拖拽换位暂存 */
let dragPane: { row: PaneRow; index: number } | null = null
/** 标签拖拽暂存(跨窗移动) */
let dragTab: { row: PaneRow; index: number; tabId: string } | null = null
/** 标签拖放目标(吸附高亮;模块级,PaneBody 与窗容器共用) */
let dropTarget: { row: PaneRow; index: number } | null = null
let dropTargetListeners: Set<() => void> = new Set()
function setDropTarget(t: { row: PaneRow; index: number } | null) {
if (dropTarget === t) return
dropTarget = t
for (const fn of dropTargetListeners) fn()
}
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
function findConversationRoot(): HTMLElement | null {
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[data-phase]'))
const ok = (el: HTMLElement) => el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT' && el.children.length >= 2
return candidates.find((el) => ok(el) && el.dataset.phase === 'active')
?? candidates.find(ok)
?? null
}
function loadSaved(layoutId: string): { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] } | null {
try {
const raw = localStorage.getItem(PERSIST_KEY)
if (!raw) return null
const s = JSON.parse(raw)?.[layoutId]
if (!s || typeof s !== 'object') return null
return {
chatW: Number.isFinite(s.chatW) ? s.chatW : -1,
topH: Number.isFinite(s.topH) ? s.topH : -1,
leftW: Number.isFinite(s.leftW) ? s.leftW : -1,
paneWs: Array.isArray(s.paneWs) ? s.paneWs : [],
topWs: Array.isArray(s.topWs) ? s.topWs : [],
leftWs: Array.isArray(s.leftWs) ? s.leftWs : [],
}
} catch {
return null
}
}
function persistSaved(layoutId: string, s: { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] }) {
try {
const raw = localStorage.getItem(PERSIST_KEY)
const all = raw ? JSON.parse(raw) : {}
all[layoutId] = s
localStorage.setItem(PERSIST_KEY, JSON.stringify(all))
} catch {}
}
/** 共享互斥协议:其他接入本协议的分栏引擎声明占用时,本引擎让位(同一时刻仅一个分栏工作区) */
window.addEventListener('dsh:split-claim', ((e: any) => {
const id = e?.detail?.id
if (splitStore.active && id && id !== splitStore.spec?.id) splitStore.close()
}) as EventListener)
export const splitStore: SplitState = {
active: false,
spec: null,
geom: null,
chatW: 320,
topH: 200,
leftW: 260,
paneWs: [],
topWs: [],
leftWs: [],
root: null,
header: null,
viewArea: null,
savedMarginLeft: '',
savedMarginRight: '',
savedMarginTop: '',
observer: null,
fallback: null,
yieldObserver: null,
lastMarginLeft: '',
lastMarginRight: '',
lastMarginTop: '',
onSpecMutated: null,
listeners: new Set(),
open(spec) {
if (this.active) {
// 反选:同一布局再点 = 关闭;不同布局 = 替换(先关旧的)
if (this.spec?.id === spec.id) {
this.close()
return true
}
this.close()
}
// 跨插件互操作桥:自带分栏实现的入驻插件(未接入共享引擎)打开时,运行时点击其关闭按钮让位
try {
const taClose = document.querySelector<HTMLElement>('.ta_splitClose')
taClose?.click()
} catch {}
// 声明占用:接入共享协议的其他引擎收到后让位
try {
window.dispatchEvent(new CustomEvent('dsh:split-claim', { detail: { id: spec.id } }))
} catch {}
const root = findConversationRoot()
if (!root) return false
const header = root.children[0] as HTMLElement | undefined
const viewArea = root.children[1] as HTMLElement | undefined
if (!header || !viewArea) return false
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' }
// 向后兼容归一化:单内容声明 → 一个标签页
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)
const hasChatW = !!saved && saved.chatW >= 0
const hasTopH = !!saved && saved.topH >= 0
const hasLeftW = !!saved && saved.leftW >= 0
const hasPaneWs = !!saved && saved.paneWs.length === main.length
const hasTopWs = !!saved && saved.topWs.length === top.length
const hasLeftWs = !!saved && saved.leftWs.length === (left ? 1 : 0)
this.chatW = hasChatW ? saved!.chatW : spec.chatWidth.default
this.topH = hasTopH ? saved!.topH : (spec.topHeight?.default ?? 200)
this.leftW = hasLeftW ? saved!.leftW : (spec.leftWidth?.default ?? 260)
this.paneWs = hasPaneWs ? [...saved!.paneWs] : main.map((p) => p.min)
this.topWs = hasTopWs ? [...saved!.topWs] : top.map((p) => p.min)
this.leftWs = hasLeftWs ? [...saved!.leftWs] : (left ? [left.min] : [])
this.root = root
this.header = header
this.viewArea = viewArea
this.savedMarginLeft = viewArea.style.marginLeft
this.savedMarginRight = viewArea.style.marginRight
this.savedMarginTop = viewArea.style.marginTop
this.refreshGeom()
// 均衡默认:无存档尺寸时按当前可用空间比例分配,
// 不再出现“其余窗全部贴 min、最后一个吃掉全部余量”的悬殊观感。
const g0 = this.geom
if (g0) {
const colW0 = g0.right - g0.left
const rowH0 = g0.bottom - g0.top
if (!hasChatW) {
const hi = Math.max(spec.chatWidth.min, colW0 - 60)
this.chatW = clamp(Math.round(colW0 * 0.3), spec.chatWidth.min, hi)
}
if (left && !hasLeftW) {
const lo = spec.leftWidth?.min ?? 160
this.leftW = clamp(Math.round(colW0 * 0.38), lo, Math.max(lo, colW0 - 260))
}
if (top.length > 0 && !hasTopH) {
const lo = spec.topHeight?.min ?? 80
const ratio = spec.topHeightRatio ?? 0.35
this.topH = clamp(Math.round((rowH0 - BAR_H) * ratio), lo, Math.max(lo, rowH0 - BAR_H - 80))
}
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 share = Math.round(avail / main.length)
this.paneWs = main.map((p) => Math.max(p.min, share))
}
if (!hasTopWs) {
// 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 share = Math.round(avail / top.length)
this.topWs = top.map((p) => Math.max(p.min, share))
}
if (left && !hasLeftWs) this.leftWs = [Math.max(left.min, this.leftW)]
}
this.applyMargin()
this.observer = new ResizeObserver(() => {
const r = this.root
if (!(r && r.isConnected && r.dataset.phase === 'active')) {
this.syncAnchor()
return
}
this.refreshGeom()
this.applyMargin()
this.notify()
})
this.observer.observe(root)
// 兜底:会话根被替换/phase 变化时 RO 可能不再回调,用 body 级 MutationObserver 驱动重锚定
this.fallback = new MutationObserver(() => {
const r = this.root
if (r && r.isConnected && r.dataset.phase === 'active') return
this.syncAnchor()
})
this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] })
// 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身
this.yieldObserver = new MutationObserver(() => {
if (!this.active || !this.viewArea) return
if (this.viewArea.style.marginLeft !== this.lastMarginLeft
|| this.viewArea.style.marginRight !== this.lastMarginRight
|| this.viewArea.style.marginTop !== this.lastMarginTop) {
this.close()
}
})
this.yieldObserver.observe(viewArea, { attributes: true, attributeFilter: ['style'] })
this.active = true
this.notify()
return true
},
/** 会话根失效(切换会话)时重新锚定:左侧内容保持不关闭;无会话才关闭 */
syncAnchor() {
if (!this.active) return
const next = findConversationRoot()
if (!next) {
this.close()
return
}
if (next.dataset.phase !== 'active') return // 过渡态:保持等待(phase 变化会再次触发)
if (next === this.root) {
this.refreshGeom()
this.applyMargin()
this.notify()
return
}
const header = next.children[0] as HTMLElement | undefined
const viewArea = next.children[1] as HTMLElement | undefined
if (!header || !viewArea) {
this.close()
return
}
// 恢复旧视图区 margin(若仍连接),锚定到新会话根
if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) {
this.viewArea.style.marginLeft = this.savedMarginLeft
this.viewArea.style.marginRight = this.savedMarginRight
this.viewArea.style.marginTop = this.savedMarginTop
}
this.root = next
this.header = header
this.viewArea = viewArea
this.savedMarginLeft = viewArea.style.marginLeft
this.savedMarginRight = viewArea.style.marginRight
this.savedMarginTop = viewArea.style.marginTop
this.observer?.disconnect()
this.observer.observe(next)
this.refreshGeom()
this.applyMargin()
this.notify()
},
refreshGeom() {
const root = this.root
const header = this.header
if (!root || !header) return
const rr = root.getBoundingClientRect()
const hr = header.getBoundingClientRect()
this.geom = { left: rr.left, top: hr.bottom, right: rr.right, bottom: rr.bottom }
},
applyMargin() {
const viewArea = this.viewArea
const g = this.geom
const spec = this.spec
if (!viewArea || !g || !spec) return
const colW = g.right - g.left
const rowH = g.bottom - g.top
const hasLeft = !!spec.left
const hasTop = !!(spec.top && spec.top.length > 0)
const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
const topH = hasTop
? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
: 0
const leftW = hasLeft
? clamp(this.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
: 0
const chatFull = spec.chatFullHeight === true
const gap = Math.max(0, colW - chatW) + 'px'
const mt = (BAR_H + (hasTop && !chatFull ? topH : 0)) + 'px'
const chatLeft = !hasLeft && spec.chatSide === 'left'
this.lastMarginLeft = hasLeft ? leftW + 'px' : (chatLeft ? '' : gap)
this.lastMarginRight = hasLeft ? '' : (chatLeft ? gap : '')
this.lastMarginTop = mt
viewArea.style.marginLeft = this.lastMarginLeft
viewArea.style.marginRight = this.lastMarginRight
viewArea.style.marginTop = mt
},
setChatW(w) {
const g = this.geom
const spec = this.spec
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 hi = Math.max(spec.chatWidth.min, colW - minContent)
this.chatW = clamp(Math.round(w), spec.chatWidth.min, hi)
this.applyMargin()
this.persist()
this.notify()
},
setTopH(h) {
const g = this.geom
const spec = this.spec
if (!g || !spec) return
const rowH = g.bottom - g.top
const lo = spec.topHeight?.min ?? 80
const hi = Math.max(lo, rowH - BAR_H - 80)
this.topH = clamp(Math.round(h), lo, hi)
this.applyMargin()
this.persist()
this.notify()
},
setLeftW(w) {
const g = this.geom
const spec = this.spec
if (!g || !spec || !spec.left) return
const colW = g.right - g.left
const lo = spec.leftWidth?.min ?? 160
const hi = Math.max(lo, colW - 260)
this.leftW = clamp(Math.round(w), lo, hi)
this.applyMargin()
this.persist()
this.notify()
},
setPaneW(i, w) {
const g = this.geom
const spec = this.spec
if (!g || !spec) return
const main = spec.main ?? []
if (i < 0 || i >= main.length) return
const colW = g.right - g.left
const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
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 next = this.paneWs.slice()
next[i] = clamp(Math.round(w), lo, hi)
this.paneWs = next
this.persist()
this.notify()
},
setTopW(i, w) {
const g = this.geom
const spec = this.spec
if (!g || !spec) return
const top = spec.top ?? []
if (i < 0 || i >= top.length) return
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 next = this.topWs.slice()
next[i] = clamp(Math.round(w), lo, hi)
this.topWs = next
this.persist()
this.notify()
},
setPaneContent(row, i, content) {
if (content) this.openTab(row, i, content)
},
lockPane(row, i, content) {
const spec = this.spec
if (!spec) return
const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content, active: 0 }
const mutate = (pane: SplitPane): SplitPane => ({ ...pane, content: null, tabs: [tab], 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()
},
openTab(row, i, content) {
const spec = this.spec
if (!spec) return
const mutate = (pane: SplitPane): SplitPane => {
const tabs = [...(pane.tabs ?? [])]
// 去重:同内容已有标签 → 直接激活
const existing = tabs.findIndex((t) => sameContent(t.content, content))
if (existing >= 0) return { ...pane, content: null, tabs, active: existing }
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: 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()
},
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()
},
moveTab(fromRow, fromI, tabId, toRow, toI) {
const spec = this.spec
if (!spec) return
if (fromRow === toRow && fromI === toI) return
const top = [...(spec.top ?? [])]
const main = [...spec.main]
const left = spec.left ? { ...spec.left } : null
const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main)
const fromArr = arrOf(fromRow)
const toArr = arrOf(toRow)
const fromPane = fromArr[fromI]
const toPane = toArr[toI]
if (!fromPane || !toPane) return
const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId)
if (!tab) return
const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId)
const toTabs = [...(toPane.tabs ?? []), tab]
const setPane = (row: PaneRow, i: number, pane: SplitPane) => {
if (row === 'left') spec.left = pane
else if (row === 'top') top[i] = pane
else main[i] = pane
}
setPane(fromRow, fromI, { ...fromPane, tabs: fromTabs, active: 0 })
setPane(toRow, toI, { ...toPane, tabs: toTabs, active: toTabs.length - 1 })
this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, 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.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
swapPanes(aRow, aI, bRow, bI) {
const spec = this.spec
if (!spec) return
const top = [...(spec.top ?? [])]
const main = [...spec.main]
const left = spec.left ? { ...spec.left } : null
const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main)
const setOf = (row: PaneRow, i: number, pane: SplitPane) => {
if (row === 'left') spec.left = pane
else if (row === 'top') top[i] = pane
else main[i] = pane
}
const a = arrOf(aRow)[aI]
const b = arrOf(bRow)[bI]
if (!a || !b) return
setOf(aRow, aI, b)
setOf(bRow, bI, a)
const wsOf = (row: PaneRow): number[] => (row === 'left' ? this.leftWs : row === 'top' ? this.topWs : this.paneWs)
const setWs = (row: PaneRow, i: number, v: number) => {
if (row === 'left') { const n = this.leftWs.slice(); n[i] = v; this.leftWs = n }
else if (row === 'top') { const n = this.topWs.slice(); n[i] = v; this.topWs = n }
else { const n = this.paneWs.slice(); n[i] = v; this.paneWs = n }
}
const aW = wsOf(aRow)[aI]
const bW = wsOf(bRow)[bI]
setWs(aRow, aI, bW)
setWs(bRow, bI, aW)
this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, main }
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
setChatSide(side) {
const spec = this.spec
if (!spec) return
if (spec.left) return // 左列布局:聊天固定右下
this.spec = { ...spec, chatSide: side }
this.onSpecMutated?.(this.spec)
this.applyMargin()
this.persist()
this.notify()
},
persist() {
if (!this.spec) return
persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, leftW: this.leftW, paneWs: this.paneWs, topWs: this.topWs, leftWs: this.leftWs })
},
close() {
if (this.viewArea) {
this.viewArea.style.marginLeft = this.savedMarginLeft
this.viewArea.style.marginRight = this.savedMarginRight
this.viewArea.style.marginTop = this.savedMarginTop
}
this.observer?.disconnect()
this.observer = null
this.fallback?.disconnect()
this.fallback = null
this.yieldObserver?.disconnect()
this.yieldObserver = null
this.root = null
this.header = null
this.viewArea = null
this.geom = null
this.spec = null
this.active = false
this.notify()
},
subscribe(fn) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
},
notify() {
for (const fn of this.listeners) fn()
},
}
/** 跨插件互操作桥:自带分栏实现的插件其浮层(.ta_split)出现 = 其引擎打开 → 本引擎让位。
* 不改动对方插件代码,仅在 DOM 层观察其浮层挂载。 */
if (typeof document !== 'undefined' && document.body) {
const taObserver = new MutationObserver(() => {
if (!splitStore.active) return
if (document.querySelector('.ta_split')) splitStore.close()
})
taObserver.observe(document.body, { childList: true, subtree: true })
}
/** 分配各窗宽度(最后一个拿余量) */
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 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
})
return out
}
/** 通用分隔线拖拽(chat/top/pane/topPane) */
function makeDividerHandler(kind: 'left' | 'chat' | 'top' | 'pane' | 'topPane', index?: number) {
return (e: any) => {
e.preventDefault()
const target = e.currentTarget as HTMLElement
try { target.setPointerCapture(e.pointerId) } catch {}
const onMove = (ev: PointerEvent) => {
const g = splitStore.geom
if (!g) return
if (kind === 'left') {
splitStore.setLeftW(ev.clientX - g.left)
} else if (kind === 'chat') {
splitStore.setChatW(g.right - ev.clientX)
} 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
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
splitStore.setTopW(index, ev.clientX - (g.left + prefix))
}
}
const onUp = () => {
target.removeEventListener('pointermove', onMove)
target.removeEventListener('pointerup', onUp)
target.removeEventListener('pointercancel', onUp)
}
target.addEventListener('pointermove', onMove)
target.addEventListener('pointerup', onUp)
target.addEventListener('pointercancel', onUp)
}
}
/** 浏览器内置窗:地址栏 + 前往;刷新统一在标签栏最左(重挂载 iframe,跨域也可靠) */
function BrowserPane(props: { reloadKey: number }) {
const [url, setUrl] = useState('https://example.com')
const [src, setSrc] = useState('https://example.com')
const go = () => {
const u = url.trim()
setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank')
}
return (
<>
<div className="dsh-wt_browserBar">
<input
className="dsh-wt_browserInput"
value={url}
placeholder="https://"
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') go() }}
/>
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
</div>
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="browser" />
</>
)
}
/** iframe 内容标签(网页/站点产物):刷新统一在标签栏最左(重挂载整页刷新,跨域可靠) */
function IframePane(props: { url: string; title?: string; reloadKey: number }) {
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={props.url} title={props.title ?? ''} />
}
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
function AnimPane(props: { reloadKey: number }) {
const [url, setUrl] = useState('')
const [src, setSrc] = useState('about:blank')
const go = () => {
const u = url.trim()
setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank')
}
return (
<>
<div className="dsh-wt_browserBar">
<input
className="dsh-wt_browserInput"
value={url}
placeholder={T('pane.animUrlPh')}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') go() }}
/>
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
</div>
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="anim" />
</>
)
}
/** 控制室面板:项目卡片网格(每行 3 张、超出换行);数据由工作台组装推送(纯读镜像)。
* 主题:dark/light 直接生效;system = 跟随宿主 html 的 color-scheme(DSH 深色/白色/跟随系统都会反映到它) */
function ConsolePane() {
const [, setTick] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [themeMode, setThemeMode] = useState<'dark' | 'light' | 'system'>(() => splitEnv?.console?.getTheme?.() ?? 'system')
const [sysDark, setSysDark] = useState(() => {
try { return window.matchMedia('(prefers-color-scheme: dark)').matches } catch { return true }
})
useEffect(() => {
const env = splitEnv?.console
if (!env) return
const bump = () => { setTick((t) => t + 1); setThemeMode(env.getTheme?.() ?? 'system') }
const un = env.subscribe(bump)
bump()
return un
}, [])
// 跟随系统:监听 OS 深浅切换(仅 themeMode==='system' 时影响渲染)
useEffect(() => {
let mq: MediaQueryList | null = null
try { mq = window.matchMedia('(prefers-color-scheme: dark)') } catch {}
if (!mq) return
const f = (e: any) => setSysDark(!!e?.matches)
try { mq.addEventListener('change', f) } catch { try { (mq as any).addListener(f) } catch {} }
return () => { try { mq.removeEventListener('change', f) } catch { try { (mq as any).removeListener(f) } catch {} } }
}, [])
// 打开控制室 = 预热所有绑定会话的最近消息(冷会话走宿主 history 只读通道)
useEffect(() => {
splitEnv?.console?.refreshPreviews?.()
}, [])
// 运行时长的分钟级刷新:每秒重渲染一次(仅控制室开着时存在)
useEffect(() => {
const iv = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(iv)
}, [])
void now
const env = splitEnv?.console
const cards = env ? env.getCards() : []
const resolvedTheme: 'dark' | 'light' = (() => {
if (themeMode !== 'system') return themeMode
try {
const cs = getComputedStyle(document.documentElement).colorScheme || ''
if (cs.includes('dark') && !cs.includes('light')) return 'dark'
if (cs.includes('light') && !cs.includes('dark')) return 'light'
} catch {}
return sysDark ? 'dark' : 'light'
})()
const setTheme = (th: 'dark' | 'light' | 'system') => { setThemeMode(th); env?.setTheme?.(th) }
const fmtDur = (ms: number) => {
const s = Math.max(0, Math.floor(ms / 1000))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = s % 60
return h > 0 ? h + ':' + String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0') : m + ':' + String(ss).padStart(2, '0')
}
const statusLabel: Record<string, string> = { idle: T('console.idle'), busy: T('console.busy'), need: T('console.need'), done: T('console.done') }
const themeOpts: { mode: 'dark' | 'light' | 'system'; icon: string; key: string }[] = [
{ mode: 'dark', icon: '🌙', key: 'console.themeDark' },
{ mode: 'light', icon: '☀️', key: 'console.themeLight' },
{ mode: 'system', icon: '🖥️', key: 'console.themeSystem' },
]
return (
<div className="dsh-wt_console" data-wt-theme={resolvedTheme}>
<div className="dsh-wt_consoleHead">
<span className="dsh-wt_consoleTitle">🖥️ {T('console.title')}</span>
<div className="dsh-wt_consoleTheme" role="group" aria-label={T('console.themeLabel')}>
{themeOpts.map((o) => (
<button
key={o.mode}
type="button"
className={'dsh-wt_consoleThemeBtn' + (themeMode === o.mode ? ' dsh-wt_consoleThemeBtnOn' : '')}
title={T(o.key)}
aria-label={T(o.key)}
onClick={() => setTheme(o.mode)}
><span aria-hidden>{o.icon}</span></button>
))}
</div>
</div>
<div className="dsh-wt_consoleGrid">
{cards.map((c) => (
<div
key={c.id}
role={c.self ? undefined : 'button'}
tabIndex={c.self ? -1 : 0}
className={'dsh-wt_consoleCard' + (c.self ? ' dsh-wt_consoleCardSelf' : '')
+ (c.status === 'busy' ? ' dsh-wt_consoleCard-busy' : '')
+ (c.glow && c.status === 'done' ? ' dsh-wt_consoleCard-glowDone' : '')
+ (c.glow && c.status === 'need' ? ' dsh-wt_consoleCard-glowNeed' : '')}
title={c.name}
onClick={() => {
if (c.self || !env) return
if (c.glow) env.onAck?.(c.id) // 点发光卡片:先确认熄光,再进入
env.onOpen(c.id)
}}
>
<div className="dsh-wt_consoleCardHead">
<span className="dsh-wt_consoleIcon" aria-hidden>{c.icon}</span>
<span className="dsh-wt_consoleName">{c.name}</span>
<span className={'dsh-wt_consoleDot dsh-wt_consoleDot-' + c.status} aria-hidden />
</div>
<div className="dsh-wt_consoleStatusRow">
<span className={'dsh-wt_consoleStatus dsh-wt_consoleStatus-' + c.status}>{statusLabel[c.status]}</span>
{c.runtimeMs != null && <span className="dsh-wt_consoleRuntime">⏱ {fmtDur(c.runtimeMs)}</span>}
</div>
<div className="dsh-wt_consoleBadges">
<span className="dsh-wt_consoleBadge">{T('console.kids')} {c.kids}</span>
</div>
<div className={'dsh-wt_consolePreview' + (c.preview ? '' : ' dsh-wt_consolePreviewNone')} title={c.preview}>
{c.preview || (c.bound ? T('console.noPreview') : T('console.unboundShort'))}
</div>
{c.bound && !c.self && (
<button
type="button"
className="dsh-wt_consoleJump"
title={T('console.jump')}
aria-label={T('console.jump')}
onClick={(e) => { e.stopPropagation(); env?.onJump(c.id) }}
>💬</button>
)}
</div>
))}
</div>
{cards.length === 0 && <div className="dsh-wt_consoleEmpty">{T('console.empty')}</div>}
</div>
)
}
/** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */
function FolderIcon() {
return (
<svg className="dsh-wt_treeIcon" width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M1.75 3.25A1.75 1.75 0 0 1 3.5 1.5h2.63a1.75 1.75 0 0 1 1.34.66l.62.79a1.75 1.75 0 0 0 1.34.66H12.5a1.75 1.75 0 0 1 1.75 1.75v7.39A1.75 1.75 0 0 1 12.5 14.5h-9a1.75 1.75 0 0 1-1.75-1.75V3.25Z" fill="var(--dsw-alias-state-accent-primary,#4f8ef7)" opacity="0.9" />
<path d="M1.75 5.75h12.5v7a1.75 1.75 0 0 1-1.75 1.75h-9a1.75 1.75 0 0 1-1.75-1.75v-7Z" fill="var(--dsw-alias-state-accent-primary,#4f8ef7)" opacity="0.4" />
</svg>
)
}
/** 文件图标(重绘 SVG) */
function FileIcon() {
return (
<svg className="dsh-wt_treeIcon" width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M4 1.5h5.25a1 1 0 0 1 .71.29l3.25 3.25a1 1 0 0 1 .29.71V13.5a1 1 0 0 1-1 1h-8.5a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1Z" fill="var(--dsw-alias-fill-l1,rgba(255,255,255,.06))" stroke="var(--dsw-alias-label-secondary,#9aa4b2)" strokeWidth="1.1" />
<path d="M9.25 1.5V4.75h3.25" fill="none" stroke="var(--dsw-alias-label-secondary,#9aa4b2)" strokeWidth="1.1" />
</svg>
)
}
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用;.html 点击开浏览器标签) */
function ExplorerPane(props: { row: PaneRow; index: number }) {
const cacheRef = useRef<Record<string, any[]>>({})
const expandedRef = useRef<Set<string>>(new Set())
const [rootPath, setRootPath] = useState('')
const [error, setError] = useState('')
const [, setTick] = useState(0)
const rerender = () => setTick((t) => t + 1)
const fetchDir = useCallback(async (path: string, force = false) => {
if (!force && cacheRef.current[path]) return { path, entries: cacheRef.current[path] }
try {
const d = await postJson('/api/worktable/fs', {
path,
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
const entries: any[] = d.entries ?? []
cacheRef.current[d.path] = entries
setError(d.error ? String(d.error) : '')
return { path: d.path, entries }
} catch (e) {
setError(String(e))
return { path, entries: [] }
} finally {
rerender()
}
}, [])
const initRoot = useCallback(async () => {
const r = await fetchDir(splitEnv?.getScope()?.cwd ?? '')
setRootPath(r.path)
rerender()
}, [fetchDir])
useEffect(() => { initRoot() }, [initRoot])
const toggle = (path: string) => {
if (expandedRef.current.has(path)) expandedRef.current.delete(path)
else { expandedRef.current.add(path); fetchDir(path) }
rerender()
}
const refresh = () => {
cacheRef.current = {}
expandedRef.current.clear()
setError('')
initRoot()
}
const goUp = () => {
if (!rootPath) return
const parent = parentPathOf(rootPath)
if (parent === rootPath) return
cacheRef.current = {}
expandedRef.current.clear()
setError('')
fetchDir(parent).then((r) => { setRootPath(r.path); rerender() })
}
const renderLevel = (path: string, depth: number): any[] => {
const entries = cacheRef.current[path]
if (!entries) return []
const nodes: any[] = []
for (const e of entries) {
const isOpen = expandedRef.current.has(e.path)
nodes.push(
<div key={e.path}>
<button
type="button"
className="dsh-wt_treeRow"
style={{ paddingLeft: 8 + depth * 14 }}
onClick={() => {
if (e.isDir) { toggle(e.path); return }
if (/\.html?$/i.test(e.name)) {
// 目录级静态托管:相对引用(./assets/...)在所在目录下解析,页面可完整渲染
const dir = parentPathOf(e.path)
splitStore.openTab(props.row, props.index, {
kind: 'iframe',
url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(e.name),
title: e.name,
})
} else if (/\.(md|markdown|mdown|txt|log|tsx|ts|jsx|js|css|json|pdf|png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(e.name)) {
splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path })
} else {
setError(T('pane.openLater'))
}
}}
>
<span className={'dsh-wt_treeArrow' + (e.isDir && isOpen ? ' dsh-wt_treeArrowOpen' : '')} aria-hidden>{e.isDir ? '▸' : ''}</span>
{e.isDir ? <FolderIcon /> : <FileIcon />}
<span className="dsh-wt_treeName">{e.name}</span>
</button>
{e.isDir && isOpen && renderLevel(e.path, depth + 1)}
</div>,
)
}
return nodes
}
return (
<>
<div className="dsh-wt_subBar">
<button type="button" className="dsh-wt_subBtn" title="上一级" onClick={goUp}>⬆</button>
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={refresh}>↻</button>
<span className="dsh-wt_subPath">{rootPath || '…'}</span>
</div>
<div className="dsh-wt_subList">
{error && <div className="dsh-wt_subEmpty">{error}</div>}
{!error && cacheRef.current[rootPath]?.length === 0 && <div className="dsh-wt_subEmpty">—</div>}
{renderLevel(rootPath, 0)}
</div>
</>
)
}
/** 源代码管理窗(服务端 /api/worktable/git) */
function GitPane() {
const [snap, setSnap] = useState<{ isRepo: boolean; branch?: string; entries: any[] } | null>(null)
const [error, setError] = useState('')
const load = useCallback(() => {
postJson('/api/worktable/git', {
sessionId: splitEnv?.getScope()?.sessionId ?? '',
cwd: splitEnv?.getScope()?.cwd ?? '',
})
.then(setSnap)
.catch((e) => setError(String(e)))
}, [])
useEffect(() => { load() }, [load])
return (
<>
<div className="dsh-wt_subBar">
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={load}>↻</button>
<span className="dsh-wt_subPath">{snap?.isRepo ? ('⎇ ' + snap.branch) : ''}</span>
</div>
<div className="dsh-wt_subList">
{error && <div className="dsh-wt_subEmpty">{error}</div>}
{!error && snap && !snap.isRepo && <div className="dsh-wt_subEmpty">{T('pane.gitNotRepo')}</div>}
{!error && snap?.isRepo && snap.entries.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.gitClean')}</div>}
{!error && snap?.isRepo && snap.entries.map((e, i) => (
<div key={i} className="dsh-wt_subRow dsh-wt_subRowStatic">
<span className={'dsh-wt_gitXY dsh-wt_gitXY' + (e.xy.includes('A') || e.xy.includes('M') ? 'Mod' : 'New')}>{e.xy.trim()}</span>
<span className="dsh-wt_subName">{e.path}</span>
</div>
))}
</div>
</>
)
}
/** 任务管理窗:后台任务 + 子代理(Agent 情况;2s 刷新) */
function JobsPane() {
const [, setTick] = useState(0)
useEffect(() => {
const timer = window.setInterval(() => setTick((t) => t + 1), 2000)
return () => window.clearInterval(timer)
}, [])
const jobs = splitEnv?.getJobs?.() ?? []
const subagents = splitEnv?.getSubagents?.() ?? []
return (
<div className="dsh-wt_subList">
<div className="dsh-wt_subSection">{T('pane.jobsTitle')}</div>
{jobs.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.jobsEmpty')}</div>}
{jobs.map((j) => (
<div key={j.id} className="dsh-wt_subRow dsh-wt_subRowStatic">
<span className={'dsh-wt_jobDot dsh-wt_jobDot-' + j.status} aria-hidden>●</span>
<span className="dsh-wt_subName">{j.label}</span>
<span className="dsh-wt_subTag">{j.kind}</span>
</div>
))}
<div className="dsh-wt_subSection">{T('pane.subagents')}</div>
{subagents.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.subagentsEmpty')}</div>}
{subagents.map((s: any, i: number) => (
<div key={s?.id ?? i} className="dsh-wt_subRow dsh-wt_subRowStatic" style={{ paddingLeft: 8 + (s?.depth ?? 0) * 12 }}>
<span className={'dsh-wt_jobDot dsh-wt_jobDot-' + (s?.status ?? 'stopping')} aria-hidden>●</span>
<span className="dsh-wt_subName">{s?.label ?? s?.title ?? s?.name ?? '—'}</span>
{s?.status && <span className="dsh-wt_subTag">{s.status}</span>}
</div>
))}
</div>
)
}
/** 终端窗(WS /api/worktable/term + node-pty) */
function TerminalPane() {
const hostRef = useRef<HTMLDivElement | null>(null)
const [failed, setFailed] = useState('')
useEffect(() => {
const el = hostRef.current
if (!el) return
let term: any = null
let ws: WebSocket | null = null
let disposed = false
try {
term = new Terminal({
cursorBlink: true,
fontFamily: 'Cascadia Code, Cascadia Mono, Consolas, Menlo, monospace',
fontSize: 12,
convertEol: true,
theme: { background: '#010409' },
})
} catch {
setFailed(T('pane.termFail'))
return
}
term.open(el)
// 强制自动换行(DECAWM on):超长行在窗口宽度处换行,不被截断
try { term.write('\x1b[?7h') } catch {}
const focusTerm = () => { try { term.focus() } catch {} }
focusTerm()
el.addEventListener('pointerdown', focusTerm)
const scope = splitEnv?.getScope?.()
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
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 {
term.dispose()
setFailed(T('pane.termFail'))
return
}
ws.onopen = () => { focusTerm(); try { term.write('\x1b[?7h') } catch {} }
ws.onmessage = (ev) => { try { term.write(String(ev.data)) } catch {} }
ws.onclose = () => { if (!disposed) { try { term.write('\r\n[连接已关闭]') } catch {} } }
ws.onerror = () => { if (!disposed) setFailed(T('pane.termFail')) }
term.onData((d: string) => { if (ws && ws.readyState === 1) ws.send(d) })
const ro = new ResizeObserver(() => {
if (typeof term.fit === 'function') {
try { term.fit() } catch {}
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
})
ro.observe(el)
return () => {
disposed = true
ro.disconnect()
try { ws?.close() } catch {}
try { term.dispose() } catch {}
}
}, [])
if (failed) {
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{failed}</span></div>
}
return <div ref={hostRef} className="dsh-wt_termHost" />
}
const mdRenderer = new MarkdownIt({ linkify: true })
const IMAGE_EXTS = /[.](png|jpe?g|gif|webp|svg|bmp|ico)$/i
const MD_EXTS = /[.](md|markdown|mdown)$/i
/** 本地文件预览:PDF 走原生 iframe(Chrome 内置阅读器)、图片居中、MD 渲染、其余纯文本 */
function FileViewer(props: { path: string }) {
const ext = (props.path.split('.').pop() || '').toLowerCase()
const fileUrl = '/api/worktable/file?path=' + encodeURIComponent(props.path)
if (ext === 'pdf') {
return <iframe className="dsh-wt_paneFrame" src={fileUrl} title={basenameOf(props.path)} />
}
if (IMAGE_EXTS.test('.' + ext)) {
return (
<div className="dsh-wt_imgView">
<img src={fileUrl} alt={basenameOf(props.path)} />
</div>
)
}
return <TextViewer path={props.path} fileUrl={fileUrl} isMd={MD_EXTS.test('.' + ext)} />
}
/** 代码文件语言映射(预览语法着色) */
const CODE_LANGS: Record<string, string> = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', css: 'css', json: 'json' }
const CODE_EXTS = /[.](tsx|ts|jsx|js|css|json)$/i
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function codeHtml(text: string, ext: string): string {
const lang = CODE_LANGS[ext] ?? ''
if (lang) {
try { return hljs.highlight(text, { language: lang }).value } catch { return escapeHtml(text) }
}
return escapeHtml(text)
}
/** 文本预览(fetch 原文 → MD 渲染 / 代码高亮 / <pre> 等宽展示);全部文本类型支持编辑/预览切换并可保存回磁盘 */
function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
const [text, setText] = useState<string | null>(null)
const [error, setError] = useState('')
const [mode, setMode] = useState<'preview' | 'edit'>('preview')
const [draft, setDraft] = useState('')
const [saving, setSaving] = useState(false)
const [saveFail, setSaveFail] = useState(false)
useEffect(() => {
let dead = false
setText(null)
setError('')
fetch(props.fileUrl)
.then((r) => {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.text()
})
.then((t) => { if (!dead) setText(t) })
.catch((e) => { if (!dead) setError(String(e)) })
return () => { dead = true }
}, [props.fileUrl])
const enterEdit = () => { setDraft(text ?? ''); setSaveFail(false); setMode('edit') }
const save = async () => {
setSaving(true)
setSaveFail(false)
try {
const r = await fetch('/api/worktable/write', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: props.path, content: draft }),
})
if (!r.ok) throw new Error('HTTP ' + r.status)
setText(draft)
setMode('preview')
} catch {
setSaveFail(true)
} finally {
setSaving(false)
}
}
if (error) {
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('file.fail')}:{error}</span></div>
}
if (text == null) {
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('file.loading')}</span></div>
}
const ext = (props.path.split('.').pop() || '').toLowerCase()
const isCode = CODE_EXTS.test('.' + ext)
return (
<>
<div className="dsh-wt_mdBar">
<button type="button" className={'dsh-wt_mdBtn' + (mode === 'preview' ? ' dsh-wt_mdBtnOn' : '')} onClick={() => setMode('preview')}>{T('file.preview')}</button>
<button type="button" className={'dsh-wt_mdBtn' + (mode === 'edit' ? ' dsh-wt_mdBtnOn' : '')} onClick={enterEdit}>{T('file.edit')}</button>
{mode === 'edit' && (
<button type="button" className="dsh-wt_mdSave" disabled={saving} onClick={save}>{saving ? '…' : T('file.save')}</button>
)}
{saveFail && <span className="dsh-wt_mdMsg">{T('file.saveFail')}</span>}
</div>
{mode === 'edit'
? <textarea className="dsh-wt_mdEdit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)} />
: props.isMd
? (
<div className="dsh-wt_fileView">
<div
className="dsh-wt_md"
dangerouslySetInnerHTML={{ __html: mdRenderer.render(text) }}
onClick={(e: any) => {
const a = e.target && e.target.closest ? (e.target.closest('a') as HTMLAnchorElement | null) : null
if (!a) return
e.preventDefault()
const href = a.getAttribute('href') || ''
if (/^(https?:|mailto:)/i.test(href)) window.open(href, '_blank', 'noopener')
}}
/>
</div>
)
: isCode
? (
<div className="dsh-wt_fileView">
<pre className="dsh-wt_code"><code dangerouslySetInnerHTML={{ __html: codeHtml(text, ext) }} /></pre>
</div>
)
: <div className="dsh-wt_fileView"><pre className="dsh-wt_txt">{text}</pre></div>}
</>
)
}
/** 自制下拉列表(原生 select 无法美化):文件夹分组标题 + 1px 细分隔线 + 选项列表 */
function SelectPop(props: {
value: string | null
groups: { title: string; items: { id: string; label: string; isCurrent?: boolean }[] }[]
placeholder?: string
onChange: (id: string) => void
}) {
const [open, setOpen] = useState(false)
const flat = props.groups.flatMap((g) => g.items)
const selected = flat.find((i) => i.id === props.value)
return (
<div className="dsh-wt_select">
<button type="button" className="dsh-wt_selectBtn" onClick={() => setOpen((v) => !v)}>
<span className={'dsh-wt_selectVal' + (selected ? '' : ' dsh-wt_selectPh')}>{selected?.label ?? props.placeholder ?? ''}</span>
<span className="dsh-wt_selectCaret" aria-hidden>▾</span>
</button>
{open && (
<div className="dsh-wt_selectList">
{props.groups.map((g, gi) => (
<Fragment key={g.title || 'g' + gi}>
{g.title && (
<>
<div className="dsh-wt_selectDivider" />
<div className="dsh-wt_selectGroup">📁 {g.title}</div>
</>
)}
{g.items.map((it) => (
<button
key={it.id}
type="button"
className={'dsh-wt_selectItem' + (it.id === props.value ? ' dsh-wt_selectItemOn' : '')}
onClick={() => { props.onChange(it.id); setOpen(false) }}
>
<span className="dsh-wt_selectItemTitle">{it.label}</span>
{it.isCurrent && <span className="dsh-wt_selectCurrent">{T('custom.sessionCurrent')}</span>}
</button>
))}
</Fragment>
))}
</div>
)}
</div>
)
}
/** 自定义窗口:居中对话框。两种模式:新建专属会话 / 发送到已有会话(默认当前会话)。 */
function CustomPane(props: { paneTitle?: string }) {
const paneTitle = props.paneTitle ?? ''
try { (window as any).__dshLastCustomPaneTitle = paneTitle } catch {}
const custom = splitEnv?.custom
const [requirement, setRequirement] = useState('')
const [projectId, setProjectId] = useState<string | null>(null)
const [projects, setProjects] = useState<{ id: string; name: string }[]>(() => custom?.getProjects?.() ?? [])
const [mode, setMode] = useState<'new' | 'existing'>('existing')
const [sessionGroups, setSessionGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([])
const [sessionId, setSessionId] = useState<string | null>(null)
// 分组(宿主工作区)三态:未分组 / 现有组 / 新建组
const [wsGroups, setWsGroups] = useState<{ id: string; title: string; path: string }[]>([])
const [groupMode, setGroupMode] = useState<'none' | 'existing' | 'new'>('none')
const [groupId, setGroupId] = useState<string | null>(null)
const [newGroupParent, setNewGroupParent] = useState('')
const [newGroupName, setNewGroupName] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const [fail, setFail] = useState('')
const [bindNote, setBindNote] = useState<'auto' | 'kept' | 'none'>('none')
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话;默认分组 = 当前会话所在工作区
useEffect(() => {
const list = custom?.getProjects?.() ?? []
setProjects(list)
const cur = custom?.currentProjectId?.() ?? null
setProjectId(cur && list.some((p) => p.id === cur) ? cur : (list[0]?.id ?? null))
setWsGroups((custom?.getWorkspaces?.() ?? []).map((w) => ({ id: w.id, title: w.title, path: w.path })))
custom?.getSessions?.().then((res) => {
setSessionGroups(res.groups)
const flat = res.groups.flatMap((g) => g.sessions)
setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null)
const curId = flat.find((s) => s.isCurrent)?.id
const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null
if (home) { setGroupMode('existing'); setGroupId(home.id) }
}).catch(() => { setSessionGroups([]) })
}, [custom])
const submit = async () => {
const text = requirement.trim()
if (!text || !projectId || !custom) return
setBusy(true)
setFail('')
try {
const proj = projects.find((p) => p.id === projectId)
const pname = proj?.name ?? projectId
if (mode === 'new') {
let group: any = { kind: 'none' }
if (groupMode === 'existing' && groupId) group = { kind: 'existing', workspaceId: groupId }
else if (groupMode === 'new') {
if (!newGroupParent.trim() || !newGroupName.trim()) { setFail(T('custom.groupNeedPath')); return }
group = { kind: 'new', parent: newGroupParent.trim(), name: newGroupName.trim() }
}
const sid = await custom.submit(projectId, pname, text, group, paneTitle)
setBindNote((custom.autoBind?.(sid) ?? 'none') as any)
} else {
if (!sessionId) return
await custom.sendToSession(sessionId, projectId, pname, text, paneTitle)
setBindNote((custom.autoBind?.(sessionId) ?? 'none') as any)
}
setDone(true)
} catch (e) {
setFail(String(e))
} finally {
setBusy(false)
}
}
if (!custom) {
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('pane.wip')}</span></div>
}
if (done) {
return (
<div className="dsh-wt_customBox">
<span className="dsh-wt_customDone" aria-hidden>✅</span>
<p className="dsh-wt_customDoneText">{mode === 'new' ? T('custom.done') : T('custom.sent')}</p>
<p className="dsh-wt_customDoneHint">{T('custom.doneHint')}</p>
{bindNote !== 'none' && (
<p className="dsh-wt_customDoneBind">{bindNote === 'auto' ? T('custom.autoBound') : T('custom.keptBinding')}</p>
)}
</div>
)
}
return (
<div className="dsh-wt_customBox">
<div className="dsh-wt_customCard">
<span className="dsh-wt_customTitle">✨ {T('custom.title')}</span>
<div className="dsh-wt_customModes">
<button type="button" className={'dsh-wt_customModeBtn' + (mode === 'existing' ? ' dsh-wt_customModeBtnOn' : '')} onClick={() => setMode('existing')}>{T('custom.modeSend')}</button>
<button type="button" className={'dsh-wt_customModeBtn' + (mode === 'new' ? ' dsh-wt_customModeBtnOn' : '')} onClick={() => setMode('new')}>{T('custom.modeNew')}</button>
</div>
<p className="dsh-wt_customHint">{mode === 'new' ? T('custom.hint') : T('custom.hintSend')}</p>
<textarea
className="dsh-wt_customInput"
autoFocus
placeholder={T('custom.placeholder')}
value={requirement}
onChange={(e) => setRequirement(e.target.value)}
/>
{mode === 'existing' && (
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.session')}</span>
<SelectPop
value={sessionId}
groups={sessionGroups.map((g) => ({
title: g.title,
items: g.sessions.map((s) => ({ id: s.id, label: s.title, isCurrent: s.isCurrent })),
}))}
placeholder={T('custom.session')}
onChange={setSessionId}
/>
</div>
)}
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.project')}</span>
<SelectPop
value={projectId}
groups={[{ title: '', items: projects.map((p) => ({ id: p.id, label: p.name })) }]}
placeholder={T('custom.project')}
onChange={setProjectId}
/>
</div>
{mode === 'new' && (
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.group')}</span>
<SelectPop
value={groupMode === 'none' ? '__none' : groupMode === 'new' ? '__new' : groupId}
groups={[{
title: '',
items: [
{ id: '__none', label: T('custom.groupNone') },
...wsGroups.map((w) => ({ id: w.id, label: w.title })),
{ id: '__new', label: T('custom.groupNew') },
],
}]}
placeholder={T('custom.group')}
onChange={(id) => {
if (id === '__none') setGroupMode('none')
else if (id === '__new') setGroupMode('new')
else { setGroupMode('existing'); setGroupId(id) }
}}
/>
</div>
)}
{mode === 'new' && groupMode === 'new' && (
<>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.groupNewParent')}</span>
<input
className="dsh-wt_customPathInput"
placeholder={T('custom.groupNewParentPh')}
value={newGroupParent}
onChange={(e) => setNewGroupParent(e.target.value)}
/>
</div>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.groupNewName')}</span>
<input
className="dsh-wt_customPathInput"
placeholder={T('custom.groupNewNamePh')}
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div>
</>
)}
<button
type="button"
className="dsh-wt_customSend"
disabled={busy || !requirement.trim() || !projectId || (mode === 'existing' && !sessionId) || (mode === 'new' && groupMode === 'new' && (!newGroupParent.trim() || !newGroupName.trim()))}
onClick={submit}
>{busy ? '…' : (mode === 'new' ? T('custom.send') : T('custom.sendToSession'))}</button>
{fail && <p className="dsh-wt_customFail">{T('custom.fail')}:{fail}</p>}
</div>
</div>
)
}
/** 单个标签页的内容渲染 */
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTitle?: string; reloadKey: number }) {
const content = props.tab.content
if (content.kind === 'iframe') {
return <IframePane url={content.url} title={content.title ?? props.tab.title} reloadKey={props.reloadKey} />
}
if (content.kind === 'file') {
return <FileViewer path={content.path} />
}
if (content.type === 'browser') return <BrowserPane reloadKey={props.reloadKey} />
if (content.type === 'anim') return <AnimPane reloadKey={props.reloadKey} />
if (content.type === 'console') return <ConsolePane />
if (content.type === 'explorer') return <ExplorerPane row={props.row} index={props.index} />
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
if (content.type === 'custom') return <CustomPane paneTitle={props.paneTitle ?? ''} />
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>
)
}
/** 需要标签栏刷新按钮的内容类型:网页类(iframe / 浏览器 / 动画)统一在标签最左放 ↻ */
function refreshableTab(t: PaneTab): boolean {
const c = t.content
return c.kind === 'iframe' || (c.kind === 'builtin' && (c.type === 'browser' || c.type === 'anim'))
}
/** 窗内容:标签页模型(无标签 = 6 选 1 选择器;标签可切换/关闭,关完回到选择器)
* 网页类标签最左侧固定一个 ↻ 刷新按钮(标签名之前),点击重挂载该标签内容。 */
function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
const { pane, row, index } = props
const tabs = pane.tabs ?? []
const active = Math.min(pane.active ?? 0, Math.max(0, tabs.length - 1))
const [reloadKeys, setReloadKeys] = useState<Record<string, number>>({})
if (tabs.length === 0) {
return <PanePicker row={row} index={index} />
}
const refreshTab = (t: PaneTab) => {
setReloadKeys((m) => ({ ...m, [t.id]: (m[t.id] ?? 0) + 1 }))
splitStore.setActiveTab(row, index, t.id)
}
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}
draggable
onDragStart={(e: any) => { dragTab = { row, index, tabId: t.id }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
onDragEnd={() => { dragTab = null; setDropTarget(null) }}
onClick={() => splitStore.setActiveTab(row, index, t.id)}
>
{refreshableTab(t) && (
<button
type="button"
className="dsh-wt_tabRefresh"
title={T('pane.refresh')}
aria-label={T('pane.refresh')}
onClick={(e) => { e.stopPropagation(); refreshTab(t) }}
>↻</button>
)}
<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>
<PaneTabBody tab={tabs[active]} row={row} index={index} paneTitle={pane.title} reloadKey={reloadKeys[tabs[active].id] ?? 0} />
</>
)
}
/** 未指派内容:4 选 1 选择器。按钮固定大小、整体居中;
* 按窗位宽高比自适应排列:宽窗横排 4 连 / 方窗 2×2 / 竖窗竖排。 */
function PanePicker(props: { row: PaneRow; index: number }) {
const [mode, setMode] = useState<'row' | 'grid' | 'col'>('grid')
const hostRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = hostRef.current
if (!el) return
const update = () => {
const w = el.clientWidth
const h = el.clientHeight
const aspect = h > 0 ? w / h : 1
setMode(aspect > 1.4 ? 'row' : aspect > 0.72 ? 'grid' : 'col')
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
const pick = (content: SplitContent) => splitStore.openTab(props.row, props.index, content)
return (
<div ref={hostRef} className={'dsh-wt_panePicker dsh-wt_panePicker-' + mode}>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'browser' })}>
<span aria-hidden>🌐</span>{T('pane.browser')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'anim' })}>
<span aria-hidden>🎬</span>{T('pane.anim')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
<span aria-hidden>📁</span>{T('pane.explorer')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
<span aria-hidden>▸_</span>{T('pane.terminal')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'custom' })}>
<span aria-hidden>✨</span>{T('pane.custom')}
</button>
</div>
)
}
type PoolItem = { spec: LayoutSpec; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }
/** 单个工作区渲染层(geom 为 null 时用 0 几何渲染,外层 display:none 保活,保留网页/MD 滚动/激活标签等状态) */
function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }) {
const g = props.geom ?? { left: 0, top: 0, right: 0, bottom: 0 }
const spec = props.spec
const top = spec.top ?? []
const main = spec.main ?? []
const hasLeft = !!spec.left
const hasTop = top.length > 0
const chatLeft = !hasLeft && spec.chatSide === 'left'
const colW = g.right - g.left
const rowH = g.bottom - g.top
const chatW = clamp(props.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
const topH = hasTop
? clamp(props.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
: 0
const leftW = hasLeft
? clamp(props.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
: 0
const chatFull = spec.chatFullHeight === true
const contentW = Math.max(0, colW - chatW)
const contentX = hasLeft ? g.left + leftW : (chatLeft ? g.left + chatW : g.left)
const topRowX = hasLeft ? g.left + leftW : contentX
const topRowW = hasLeft ? Math.max(0, colW - leftW) : (chatFull ? contentW : colW)
const topItems = allocate(top, props.topWs, topRowW)
const mainItems = allocate(main, props.paneWs, contentW)
const leftItem = spec.left ? { pane: spec.left, left: 0, width: leftW } : null
const barTop = g.top
const bodyTop = barTop + BAR_H + topH
const paneBottom = g.bottom
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) => (
<div
key={it.pane.id}
className="dsh-wt_pane"
data-drop-hover={dropTarget && dropTarget.row === row && dropTarget.index === index ? 'true' : undefined}
style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}
onDragOver={(e: any) => {
if (!dragTab) return
e.preventDefault()
setDropTarget({ row, index })
}}
onDragLeave={(e: any) => {
if (e.currentTarget && !e.currentTarget.contains(e.relatedTarget)) setDropTarget(null)
}}
onDrop={(e: any) => {
e.preventDefault()
const s = dragTab
dragTab = null
setDropTarget(null)
if (s && (s.row !== row || s.index !== index)) {
splitStore.moveTab(s.row, s.index, s.tabId, row, index)
}
}}
>
<div
className="dsh-wt_paneBar"
title={T('split.dragSwap')}
draggable
onDragStart={(e: any) => { dragPane = { row, index }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
onDragOver={(e: any) => e.preventDefault()}
onDrop={(e: any) => {
e.preventDefault()
const s = dragPane
if (s && (s.row !== row || s.index !== index)) splitStore.swapPanes(s.row, s.index, row, index)
dragPane = null
}}
onDragEnd={() => { dragPane = null }}
>
<span className="dsh-wt_paneTitle">{it.pane.title}</span>
</div>
<PaneBody pane={it.pane} row={row} index={index} />
</div>
)
return (
<>
{/* 标题栏 */}
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: g.left, top: barTop, width: hasLeft || chatFull ? contentW : (hasTop ? colW : contentW), zIndex: 70 }}>
<span className="dsh-wt_splitTitle">{spec.title}</span>
{!hasLeft && (
<button
type="button"
className="dsh-wt_splitFlip"
title={T('split.flip')}
onClick={() => splitStore.setChatSide(chatLeft ? 'right' : 'left')}
>⇄</button>
)}
<button type="button" className="dsh-wt_splitClose" aria-label="退出分栏(Esc)" onClick={() => splitStore.close()}>✕</button>
</div>
{/* 左列整高内容窗 */}
{leftItem && renderPane(leftItem, 'left', 0, g.left, barTop + BAR_H, g.bottom - barTop - BAR_H)}
{/* 顶部通栏行(左列布局时为右侧列顶行) */}
{hasTop && topItems.map((it, i) => renderPane(it, 'top', i, topRowX, topY, topH))}
{/* 主行内容窗 */}
{mainItems.map((it, i) => renderPane(it, 'main', i, contentX, bodyTop, mainH))}
{/* 顶部/主行水平分隔线 */}
{hasTop && (
<div
className="dsh-wt_splitDivider dsh-wt_splitDividerH"
role="separator"
title="拖动调整上下分区"
style={{ position: 'fixed', left: topRowX, top: bodyTop - DIVIDER / 2, width: topRowW, height: DIVIDER, zIndex: 72 }}
onPointerDown={makeDividerHandler('top')}
/>
)}
{/* 顶部行内垂直分隔线 */}
{hasTop && topItems.slice(0, -1).map((it, i) => (
<div
key={'tv' + it.pane.id}
className="dsh-wt_splitDivider"
role="separator"
title="拖动调整宽度"
style={{ position: 'fixed', left: topRowX + it.left + it.width, top: topY, width: DIVIDER, height: topH, zIndex: 72 }}
onPointerDown={makeDividerHandler('topPane', i)}
/>
))}
{/* 主行内容窗垂直分隔线 */}
{mainItems.slice(0, -1).map((it, i) => (
<div
key={'v' + it.pane.id}
className="dsh-wt_splitDivider"
role="separator"
title="拖动调整宽度"
style={{ position: 'fixed', left: contentX + it.left + it.width, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
onPointerDown={makeDividerHandler('pane', i)}
/>
))}
{/* 聊天分隔线(左列布局 = 左/右列边界;其余 = 内容与聊天之间) */}
<div
className="dsh-wt_splitDivider"
role="separator"
title={hasLeft ? '拖动调整左右列宽' : '拖动调整聊天宽度'}
style={{
position: 'fixed',
left: (hasLeft ? g.left + leftW : (chatLeft ? g.left + chatW : g.right - chatW)) - DIVIDER / 2,
top: hasLeft || chatFull ? barTop + BAR_H : bodyTop,
width: DIVIDER,
height: hasLeft || chatFull ? g.bottom - barTop - BAR_H : mainH,
zIndex: 72,
}}
onPointerDown={makeDividerHandler(hasLeft ? 'left' : 'chat')}
/>
</>
)
}
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染)。
* 切换项目时旧工作区不销毁:全部挂载在池中、仅当前可见(display:none 保活),
* 网页子页面/滚动位置、MD 滚动位置、激活标签等在切回时原样保留。 */
function SplitWorkspace() {
const [snap, setSnap] = useState({
active: splitStore.active,
spec: splitStore.spec,
geom: splitStore.geom,
chatW: splitStore.chatW,
topH: splitStore.topH,
leftW: splitStore.leftW,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
const poolRef = useRef<Map<string, PoolItem>>(new Map())
const [, setPoolTick] = useState(0)
useEffect(() => splitStore.subscribe(() => {
const spec = splitStore.spec
if (splitStore.active && spec) {
poolRef.current.set(spec.id, {
spec,
chatW: splitStore.chatW,
topH: splitStore.topH,
leftW: splitStore.leftW,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
// 保活池上限 6 个(LRU:删最老的),避免长时间使用内存膨胀
while (poolRef.current.size > 6) {
const first = poolRef.current.keys().next().value
if (first != null) poolRef.current.delete(first)
}
}
setSnap({
active: splitStore.active,
spec: splitStore.spec,
geom: splitStore.geom,
chatW: splitStore.chatW,
topH: splitStore.topH,
leftW: splitStore.leftW,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
setPoolTick((t) => t + 1)
}), [])
useEffect(() => {
if (!snap.active) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') splitStore.close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [snap.active])
// 标签拖放高亮 → 重渲染
const [, setDropTick] = useState(0)
useEffect(() => {
const fn = () => setDropTick((t) => t + 1)
dropTargetListeners.add(fn)
return () => { dropTargetListeners.delete(fn) }
}, [])
const activeId = snap.active && snap.spec ? snap.spec.id : null
const entries = Array.from(poolRef.current.entries())
if (entries.length === 0) return null
return (
<>
{entries.map(([id, item]) => {
const isActive = id === activeId
return (
<div key={id} style={isActive ? undefined : { visibility: 'hidden' as const }}>
<WorkspaceLayer
spec={item.spec}
geom={isActive ? snap.geom : null}
chatW={isActive ? snap.chatW : item.chatW}
topH={isActive ? snap.topH : item.topH}
leftW={isActive ? snap.leftW : item.leftW}
paneWs={isActive ? snap.paneWs : item.paneWs}
topWs={isActive ? snap.topWs : item.topWs}
/>
</div>
)
})}
</>
)
}
/** 调试出口(自动化验证用;必须在 store 定义之后) */
try { (window as any).__dshWorktable = { splitStore } } catch {}
export { SplitWorkspace }