diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 93e834f..1d6865a 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' -import { splitStore, SplitWorkspace, type LayoutSpec } from './split' +import { splitStore, SplitWorkspace, setSplitT, type LayoutSpec, type SplitPane } from './split' /** * dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。 @@ -72,20 +72,16 @@ const PRESET_DEFS = [ { id: 'grid', topCount: 2, contentCount: 1 }, ] as const -function presetTotal(def: { topCount: number; contentCount: number }): number { - return def.topCount + def.contentCount -} - -function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec { +function buildLayout(presetId: string, name: string): LayoutSpec { const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0] - const mk = (i: number, url: string) => ({ + const mk = (i: number): SplitPane => ({ id: 'p' + (i + 1), title: '内容' + (i + 1), min: 200, - content: { kind: 'iframe' as const, url }, + content: null, }) - const top = Array.from({ length: def.topCount }, (_, i) => mk(i, urls[i] ?? 'about:blank')) - const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i, urls[def.topCount + i] ?? 'about:blank')) + const top = Array.from({ length: def.topCount }, (_, i) => mk(i)) + const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i)) return { id: 'layout-' + Date.now().toString(36), title: name, @@ -93,9 +89,37 @@ function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec main, chatWidth: { default: 360, min: 240, max: 600 }, topHeight: { default: 200, min: 120, max: 480 }, + chatSide: 'right', } } +/** 布局缩略图(迷你窗格示意;聊天窗蓝色 💬) */ +function presetThumb(defId: string) { + const cell = (chat: boolean, key: string) => ( + {chat ? '💬' : ''} + ) + if (defId === '2h') { + return {cell(false, 'a')}{cell(true, 'b')} + } + if (defId === '3h') { + return {cell(false, 'a')}{cell(false, 'b')}{cell(true, 'c')} + } + if (defId === 't2') { + return ( + + {cell(false, 'a')} + {cell(false, 'b')}{cell(true, 'c')} + + ) + } + return ( + + {cell(false, 'a')}{cell(false, 'b')} + {cell(false, 'c')}{cell(true, 'd')} + + ) +} + const DEFAULT_VIEW: ViewState = { query: '', searchOpen: false, @@ -207,7 +231,6 @@ function WorktableSection(props: any) { const [scError, setScError] = useState(false) const [wsPreset, setWsPreset] = useState('2h') const [wsName, setWsName] = useState('') - const [wsUrls, setWsUrls] = useState(['', '', '']) const [wsError, setWsError] = useState(false) const [float, setFloat] = useState(() => view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null, @@ -252,6 +275,20 @@ function WorktableSection(props: any) { setActiveSplitId(splitStore.active && splitStore.spec ? splitStore.spec.id : null) }), []) + // 引擎内 spec 变更(窗内容/聊天位置/窗位互换)→ 回写布局条目持久化 + useEffect(() => { + splitStore.onSpecMutated = (spec) => { + persistProjects((prev) => ({ ...prev, layouts: prev.layouts.map((l) => (l.id === spec.id ? spec : l)) })) + } + return () => { splitStore.onSpecMutated = null } + }, []) + + // 分栏引擎 UI 文案(窗选择器等) + useEffect(() => { + setSplitT((k) => t(k as WorktableKey)) + return () => setSplitT(null) + }, [t]) + // 侧边栏折叠/展开:保持原停靠位置(不再回弹 footer);折叠态由项目图标框承载。 // 折叠且浮动时:等折叠动画结束(320/750ms 双次重测,取收敛后的几何)再以 fixed 定位 // 图标框到拖前高度——避免在过渡帧上测得展开态宽度导致图标框偏离窄栏中心。 @@ -513,13 +550,10 @@ function WorktableSection(props: any) { // ── 布局(新建工作区) ── const saveLayout = () => { const name = wsName.trim() - const def = PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0] - const total = presetTotal(def) - const urls = wsUrls.slice(0, total).map((u) => u.trim()) - if (!name || urls.some((u) => !/^(\/|https?:\/\/)/i.test(u))) { setWsError(true); return } - const layout = buildLayout(wsPreset, name, urls) + if (!name) { setWsError(true); return } + const layout = buildLayout(wsPreset, name) persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout] })) - setWsName(''); setWsUrls(['', '', '']); setWsError(false) + setWsName(''); setWsError(false) setAddOpen(false) openSplit(layout) reportUsed(layout.id) @@ -695,11 +729,28 @@ function WorktableSection(props: any) { {addOpen && (
- {t('add.guideTitle')} -

{t('add.guideBody')}

- - {t('add.market')} ↗ - + {t('add.chooseLayout')} +
+ {PRESET_DEFS.map((def) => ( + + ))} +
+
+ { setWsName(e.target.value); setWsError(false) }} /> + +
+ {wsError &&

{t('add.layoutInvalid')}

} +
{t('add.shortcutTitle')}

{t('add.shortcutDesc')}

@@ -712,40 +763,6 @@ function WorktableSection(props: any) {
{scError &&

{t('add.shortcutInvalid')}

} -
- {t('add.workspaceTitle')} -

{t('add.workspaceDesc')}

-
- {PRESET_DEFS.map((def) => ( - - ))} -
-
- { setWsName(e.target.value); setWsError(false) }} /> - {Array.from({ length: presetTotal(PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0]) }, (_, i) => ( - { - setWsUrls((prev) => { const next = [...prev]; next[i] = e.target.value; return next }) - setWsError(false) - }} - /> - ))} - -
- {wsError &&

{t('add.layoutInvalid')}

}
)} diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index e7c6fd9..1ea9ae2 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -41,17 +41,26 @@ export const zh = { 'shortcut.badge': '本地', 'layout.badge': '布局', 'layout.desc': '{n} 内容窗 · 对话', - 'add.workspaceTitle': '新建工作区', - 'add.workspaceDesc': '选一个拓扑,填好各窗内容,保存为工作台布局(其中一窗恒为对话)。', + 'add.chooseLayout': '选择布局模式', 'add.layoutNamePh': '布局名称', - 'add.paneUrlPh': '内容地址(/xxx/ 或 https://)', - 'add.layoutSave': '保存并打开', - 'add.layoutInvalid': '请填写布局名称,且每个内容地址以 / 或 http(s) 开头', + 'add.layoutSave': '进入工作区', + 'add.layoutInvalid': '请填写布局名称', 'preset.2h': '左右两栏', 'preset.3h': '三栏横排', 'preset.t2': '上一下二', 'preset.grid': '井字四栏', 'manage.deleteLayout': '删除布局', + 'pane.browser': '浏览器', + 'pane.explorer': '资源管理器', + 'pane.scm': '源代码管理', + 'pane.tasks': '任务管理', + 'pane.terminal': '终端', + 'pane.custom': '自定义', + 'pane.customUrlPh': '地址(/xxx/ 或 https://)', + 'pane.open': '打开', + 'pane.wip': '开发中(后续版本接入)', + 'split.flip': '切换对话窗位置(左⇄右)', + 'split.dragSwap': '拖动标题栏与其他窗交换位置', 'empty': '暂无项目', } satisfies Record @@ -95,17 +104,26 @@ export const en = { 'shortcut.badge': 'Local', 'layout.badge': 'Layout', 'layout.desc': '{n} content panes · chat', - 'add.workspaceTitle': 'New workspace', - 'add.workspaceDesc': 'Pick a topology, fill in the pane contents, save as a worktable layout (one pane is always the chat).', + 'add.chooseLayout': 'Choose a layout', 'add.layoutNamePh': 'Layout name', - 'add.paneUrlPh': 'Content URL (/xxx/ or https://)', - 'add.layoutSave': 'Save & open', - 'add.layoutInvalid': 'Enter a layout name, and each content URL must start with / or http(s)', + 'add.layoutSave': 'Enter workspace', + 'add.layoutInvalid': 'Enter a layout name', 'preset.2h': 'Side by side', 'preset.3h': 'Three columns', 'preset.t2': 'Top + two', 'preset.grid': '2×2 grid', 'manage.deleteLayout': 'Delete layout', + 'pane.browser': 'Browser', + 'pane.explorer': 'Explorer', + 'pane.scm': 'Source control', + 'pane.tasks': 'Tasks', + 'pane.terminal': 'Terminal', + 'pane.custom': 'Custom', + 'pane.customUrlPh': 'URL (/xxx/ or https://)', + 'pane.open': 'Open', + 'pane.wip': 'Coming in a later version', + 'split.flip': 'Flip chat side (left ⇄ right)', + 'split.dragSwap': 'Drag the title bar to swap panes', 'empty': 'No projects yet', } satisfies Record diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 63c45a1..3fb1d9d 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -2,29 +2,37 @@ import { useEffect, useState } from 'react' /** * dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。 - * 布局模型:标题栏 + 顶部通栏行(可选)+ 主行(内容窗,从左到右)+ 右下聊天窗(官方会话视图区整体)。 - * 聊天窗几何 = marginLeft + marginTop 组合挤法;会话切换重新锚定(§12.4,切会话不关闭)。 - * 宽度持久化:dsh.worktable.split.v1 = { [layoutId]: { chatW, topH, paneWs, topWs } }。 + * 布局模型:标题栏 + 顶部通栏行(可选) + 主行内容窗 + 聊天窗(官方会话视图区整体, + * 贴右或贴左,由 chatSide 决定;marginLeft/marginRight + marginTop 组合挤法)。 + * 内容三态:null(未指派 → 6 选 1 选择器)/ iframe / builtin(浏览器/资源管理器/SCM/任务/终端)。 + * 窗位调整:标题栏拖拽换位(同行或跨行);工具栏 ⇄ 切换聊天窗左右。 + * 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v1; + * 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。 */ -export type SplitContent = { kind: 'iframe'; url: string } +export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal' -export type SplitPane = { id: string; title: string; min: number; content: SplitContent } +export type SplitContent = + | { kind: 'iframe'; url: string } + | { kind: 'builtin'; type: BuiltinType } + +export type SplitPane = { id: string; title: string; min: number; content: SplitContent | null } export type LayoutSpec = { id: string title: string - /** 顶部通栏行(可选;每项为内容窗,横跨整列宽) */ top: SplitPane[] | null - /** 主行内容窗(从左到右;聊天窗恒在最后、由引擎自动追加) */ main: SplitPane[] chatWidth: { default: number; min: number; max: number } - /** 顶部行高度(存在 top 行时生效) */ topHeight?: { default: number; min: number; max: number } + /** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */ + chatSide?: 'left' | 'right' } type Geom = { left: number; top: number; right: number; bottom: number } +type PaneRow = 'top' | 'main' + type SplitState = { active: boolean spec: LayoutSpec | null @@ -37,12 +45,15 @@ type SplitState = { 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 @@ -53,6 +64,9 @@ type SplitState = { setTopH(h: number): void setPaneW(i: number, w: number): void setTopW(i: number, w: number): void + setPaneContent(row: PaneRow, i: number, content: SplitContent | null): void + swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void + setChatSide(side: 'left' | 'right'): void persist(): void subscribe(fn: () => void): () => void notify(): void @@ -63,6 +77,25 @@ const DIVIDER = 6 const BAR_H = 26 const PERSIST_KEY = 'dsh.worktable.split.v1' +/** 内置内容窗图标 */ +const BUILTIN_ICONS: Record = { + browser: '🌐', + explorer: '📁', + scm: '🔀', + tasks: '✅', + terminal: '▸_', +} + +/** 分栏 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) + +/** 拖拽换位暂存 */ +let dragPane: { row: PaneRow; index: number } | null = null + /** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */ function findConversationRoot(): HTMLElement | null { const candidates = Array.from(document.querySelectorAll('[data-phase]')) @@ -116,12 +149,15 @@ export const splitStore: SplitState = { header: null, viewArea: null, savedMarginLeft: '', + savedMarginRight: '', savedMarginTop: '', observer: null, fallback: null, yieldObserver: null, lastMarginLeft: '', + lastMarginRight: '', lastMarginTop: '', + onSpecMutated: null, listeners: new Set(), open(spec) { @@ -147,7 +183,7 @@ export const splitStore: SplitState = { const header = root.children[0] as HTMLElement | undefined const viewArea = root.children[1] as HTMLElement | undefined if (!header || !viewArea) return false - this.spec = spec + this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' } const main = spec.main ?? [] const top = spec.top ?? [] const saved = loadSaved(spec.id) @@ -159,6 +195,7 @@ export const splitStore: SplitState = { this.header = header this.viewArea = viewArea this.savedMarginLeft = viewArea.style.marginLeft + this.savedMarginRight = viewArea.style.marginRight this.savedMarginTop = viewArea.style.marginTop this.refreshGeom() this.applyMargin() @@ -183,7 +220,9 @@ export const splitStore: SplitState = { // 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身 this.yieldObserver = new MutationObserver(() => { if (!this.active || !this.viewArea) return - if (this.viewArea.style.marginLeft !== this.lastMarginLeft || this.viewArea.style.marginTop !== this.lastMarginTop) { + if (this.viewArea.style.marginLeft !== this.lastMarginLeft + || this.viewArea.style.marginRight !== this.lastMarginRight + || this.viewArea.style.marginTop !== this.lastMarginTop) { this.close() } }) @@ -217,12 +256,14 @@ export const splitStore: SplitState = { // 恢复旧视图区 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) @@ -252,11 +293,14 @@ export const splitStore: SplitState = { const topH = hasTop ? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80)) : 0 - const ml = Math.max(0, colW - chatW) + 'px' + const gap = Math.max(0, colW - chatW) + 'px' const mt = (BAR_H + topH) + 'px' - this.lastMarginLeft = ml + const chatLeft = spec.chatSide === 'left' + this.lastMarginLeft = chatLeft ? '' : gap + this.lastMarginRight = chatLeft ? gap : '' this.lastMarginTop = mt - viewArea.style.marginLeft = ml + viewArea.style.marginLeft = this.lastMarginLeft + viewArea.style.marginRight = this.lastMarginRight viewArea.style.marginTop = mt }, @@ -323,6 +367,62 @@ export const splitStore: SplitState = { this.notify() }, + setPaneContent(row, i, content) { + const spec = this.spec + if (!spec) return + const top = [...(spec.top ?? [])] + const main = [...spec.main] + const arr = row === 'top' ? top : main + if (!arr[i]) return + arr[i] = { ...arr[i], content } + this.spec = { ...spec, top: top.length > 0 ? top : null, main } + this.onSpecMutated?.(this.spec) + this.persist() + this.notify() + }, + + swapPanes(aRow, aI, bRow, bI) { + const spec = this.spec + if (!spec) return + const top = [...(spec.top ?? [])] + const main = [...spec.main] + const arrA = aRow === 'top' ? top : main + const arrB = bRow === 'top' ? top : main + const a = arrA[aI] + const b = arrB[bI] + if (!a || !b) return + arrA[aI] = b + arrB[bI] = a + if (aRow === bRow) { + const ws = aRow === 'top' ? this.topWs.slice() : this.paneWs.slice() + const t = ws[aI]; ws[aI] = ws[bI]; ws[bI] = t + if (aRow === 'top') this.topWs = ws + else this.paneWs = ws + } else { + const aWs = aRow === 'top' ? this.topWs.slice() : this.paneWs.slice() + const bWs = bRow === 'top' ? this.topWs.slice() : this.paneWs.slice() + const ta = aWs[aI]; const tb = bWs[bI] + aWs[aI] = tb + bWs[bI] = ta + if (aRow === 'top') { this.topWs = aWs; this.paneWs = bWs } + else { this.topWs = bWs; this.paneWs = aWs } + } + this.spec = { ...spec, top: top.length > 0 ? top : null, main } + this.onSpecMutated?.(this.spec) + this.persist() + this.notify() + }, + + setChatSide(side) { + const spec = this.spec + if (!spec) 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, paneWs: this.paneWs, topWs: this.topWs }) @@ -331,6 +431,7 @@ export const splitStore: SplitState = { 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() @@ -404,6 +505,100 @@ function makeDividerHandler(kind: 'chat' | 'top' | 'pane' | 'topPane', index?: n } } +/** 浏览器内置窗:地址栏 + iframe */ +function BrowserPane() { + const [url, setUrl] = useState('https://www.bing.com') + const [src, setSrc] = useState('https://www.bing.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() }} + /> + +
+