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' 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 = { browser: '🌐', anim: '🎬', explorer: '📁', scm: '🔀', tasks: '✅', terminal: '▸_', custom: '✨', } const BUILTIN_LABEL_KEYS: Record = { browser: 'pane.browser', anim: 'pane.anim', explorer: 'pane.explorer', scm: 'pane.scm', tasks: 'pane.tasks', terminal: 'pane.terminal', custom: 'pane.custom', } 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) => string) | null = null export function setSplitT(fn: ((key: string) => string) | null) { uiT = fn } const T = (key: string): string => (uiT ? uiT(key) : 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 } 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 sendToSession: (sessionId: string, projectName: string, requirement: string) => Promise } } let splitEnv: SplitEnv | null = null export function setSplitEnv(env: SplitEnv | null) { splitEnv = env } async function postJson(url: string, body: unknown): Promise { 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('[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('.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 ( <>
setUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') go() }} />