diff --git a/README.md b/README.md index b7f1164..ddba404 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ control panel — **isolated per conversation session**. - Editor: 文本编辑(脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览 - Terminal: xterm.js + node-pty(每会话最多 3 个,进程跨刷新存活) - Git: status / diff / stage / commit / branch / history(基础集) -- 分栏工作台: 上下左右递归分栏、分隔线拖拽、Tab 拖拽移动、面板宽度拖拽 +- 分栏工作台: VSCode 式拖拽分栏(拖 Tab 到分栏边缘即左右/上下拆分,拖到中间合并)、 + 分隔线拖拽、面板宽度拖拽;折叠后右缘展开按钮常驻 - Tab 栏: 最大宽度 160px、横向滚动、`+` 菜单新建 Tab - 拦截: 对话"产出文件"行点击改为在侧边栏打开 - 样式完全复用 `--dsw-*` 主题变量与 `@deepseek-ai/dsh-client-ui-primitives` diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 213d11e..2a9c3eb 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -12,12 +12,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronRightOutline14, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { - allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, openTab, resizeSplit, - sidebarStore, splitPane, setWidth, toggleExpanded, togglePanel, - TERMINAL_LIMIT, type SidebarState, type SidebarTab, type SplitNode, + allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, + resizeSplit, sidebarStore, setWidth, toggleExpanded, togglePanel, + TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarTab, type SplitNode, } from './state.ts' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' @@ -113,15 +113,15 @@ export function Sidebar(props: { ctx: Context }) { })) }, focusPane: (paneId) => { sidebarStore.reduce(s => ({ ...s, activePane: paneId })) }, - splitPane: (dir) => { sidebarStore.reduce(s => splitPane(s, dir)) }, - moveTab: (payload: TabDragPayload, toPane: string, before: string | null) => { + moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => { + sidebarStore.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone)) + }, + moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => { sidebarStore.reduce((s) => { let index = -1 - if (before !== null) { - const source = leafWithTab(s.splits, before) - if (source !== undefined && source.id === toPane) { - index = source.tabs.findIndex(tab => tab.id === before) - } + const source = leafWithTab(s.splits, beforeTabId) + if (source !== undefined && source.id === toPane) { + index = source.tabs.findIndex(tab => tab.id === beforeTabId) } return moveTab(s, payload.paneId, payload.tabId, toPane, index) }) @@ -181,11 +181,11 @@ export function Sidebar(props: { ctx: Context }) { <> {!state.panelOpen && (
- +
diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 4009531..a5c52b6 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -8,11 +8,10 @@ import { useState, type ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, - IconChevronUpOutline14, IconCloseFill14, IconPlusOutline16, Menu, + IconCloseFill14, IconPlusOutline16, Menu, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarTab } from './state.ts' -import { t, type CopyKey } from './locales.ts' +import { t } from './locales.ts' import css from './sidebar.module.css' /** One + menu option. */ @@ -44,27 +43,19 @@ export function parseDrag(raw: string): TabDragPayload | null { } } -const SPLIT_DIRS: Array<{ dir: 'left' | 'right' | 'up' | 'down'; label: CopyKey; icon: (p: { size?: number }) => ReactNode }> = [ - { dir: 'left', label: 'splitLeft', icon: IconChevronLeftOutline14 }, - { dir: 'right', label: 'splitRight', icon: IconChevronRightOutline14 }, - { dir: 'up', label: 'splitUp', icon: IconChevronUpOutline14 }, - { dir: 'down', label: 'splitDown', icon: IconChevronDownOutline14 }, -] - export function TabBar(props: { paneId: string tabs: SidebarTab[] active: string | null onActivate: (tabId: string) => void onClose: (tabId: string) => void - onSplit: (dir: 'row' | 'col') => void onNewTab: (optionId: string) => void newTabOptions: NewTabOption[] /** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */ onDropTab: (payload: TabDragPayload, before: string | null) => void }) { const { - paneId, tabs, active, onActivate, onClose, onSplit, onNewTab, newTabOptions, onDropTab, + paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, } = props const [menuOpen, setMenuOpen] = useState(false) const [dragOver, setDragOver] = useState(false) @@ -122,18 +113,6 @@ export function TabBar(props: { ))}
- {SPLIT_DIRS.map(({ dir, label, icon: Icon }) => ( - - ))} { setMenuOpen(false) }} diff --git a/src/client/locales.ts b/src/client/locales.ts index 5814916..7948223 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -16,6 +16,8 @@ const zh = { newTerminal: '新终端', terminalLimit: '终端数量已达上限 (3)', close: '关闭', + collapse: '折叠侧边栏', + expand: '展开侧边栏', refresh: '刷新', save: '保存', saved: '已保存', @@ -71,6 +73,8 @@ const en: Record = { newTerminal: 'New terminal', terminalLimit: 'Terminal limit reached (3)', close: 'Close', + collapse: 'Collapse sidebar', + expand: 'Expand sidebar', refresh: 'Refresh', save: 'Save', saved: 'Saved', diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index c8be83e..feb92c7 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1,8 +1,9 @@ /** - * Sidebar styles: everything rides the DSH theme tokens (--dsw-alias-*, - * --dsw-static-*, --ds-transition-*) so the plugin matches the host UI in - * both light and dark themes, plus a few absolute z-indexes for the - * document-level overlay (below the app's modal layer). + * Sidebar styles. Per the DSH web-styling rules every visual value rides the + * theme: --dsw-alias-* semantic tokens for colors, --dsw-font-* typography + * roles (size paired with line-height), --dsw-shadow-* for elevation, and + * --ds-* for motion. No static palette values, no color literals, no theme + * selectors — light/dark adaptation is the theme package's job. */ /* ── Shell ─────────────────────────────────────────────────────────────── */ @@ -21,14 +22,15 @@ display: flex; align-items: center; justify-content: center; - width: 22px; - height: 56px; + width: 26px; + height: 64px; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); border-right: none; border-radius: 10px 0 0 10px; background: var(--dsw-alias-button-floating-fill); color: var(--dsw-alias-label-secondary); cursor: pointer; + box-shadow: var(--dsw-shadow-lv1); transition: background var(--ds-transition-duration-slow) var(--ds-ease-in-out), color var(--ds-transition-duration-slow) var(--ds-ease-in-out); @@ -54,7 +56,7 @@ flex-direction: column; background: var(--dsw-alias-bg-layer-1); border-left: 1px solid var(--dsw-alias-border-l2); - box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08); + box-shadow: var(--dsw-shadow-lv2); } .panelResize { @@ -83,8 +85,7 @@ } .panelTitle { - font-size: 12px; - font-weight: 600; + font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; @@ -178,6 +179,7 @@ } .pane { + position: relative; flex: 1; min-width: 0; min-height: 0; @@ -191,6 +193,53 @@ outline-offset: -1px; } +/* VSCode-style drag-to-edge drop zones (25% edges, 50% center). */ +.dropOverlay { + position: absolute; + z-index: 6; + pointer-events: none; + background: var(--dsw-alias-interactive-bg-hover-accent); + opacity: 0.5; +} + +.dropLeft { + left: 0; + top: 0; + bottom: 0; + width: 25%; +} + +.dropRight { + right: 0; + top: 0; + bottom: 0; + width: 25%; +} + +.dropUp { + top: 0; + left: 0; + right: 0; + height: 25%; +} + +.dropDown { + bottom: 0; + left: 0; + right: 0; + height: 25%; +} + +.dropCenter { + left: 25%; + right: 25%; + top: 25%; + bottom: 25%; + background: transparent; + outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent); + outline-offset: -2px; +} + .paneContent { flex: 1; min-height: 0; @@ -206,7 +255,7 @@ justify-content: center; padding: 16px; text-align: center; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); } @@ -246,7 +295,7 @@ align-items: center; gap: 4px; padding: 0 4px 0 10px; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-secondary); border-right: 1px solid var(--dsw-alias-border-l1); background: transparent; @@ -338,8 +387,7 @@ } .explorerRoot { - font-size: 12px; - font-weight: 600; + font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; @@ -362,7 +410,7 @@ padding: 0 8px; border: none; background: transparent; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-primary); text-align: left; cursor: pointer; @@ -374,7 +422,7 @@ } .explorerDir { - font-weight: 500; + font: var(--dsw-font-xxs-strong-12); } .explorerHidden { @@ -387,13 +435,14 @@ } .explorerError { - color: var(--dsw-static-red-500, #ef4444); + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-state-error-primary); cursor: default; } .explorerEmpty { padding: 16px; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); text-align: center; } @@ -419,8 +468,7 @@ .editorTitle { flex: 1; min-width: 0; - font-size: 12px; - font-weight: 600; + font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; @@ -428,19 +476,19 @@ } .editorStatus { - font-size: 11px; + font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); } .editorStatusError { - color: var(--dsw-static-red-500, #ef4444); + color: var(--dsw-alias-state-error-primary); } .dirtyDot { width: 7px; height: 7px; border-radius: 50%; - background: var(--dsw-static-amber-500, #f59e0b); + background: var(--dsw-alias-state-warn-primary); flex: none; } @@ -449,7 +497,7 @@ display: flex; align-items: center; justify-content: center; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); padding: 16px; text-align: center; @@ -457,16 +505,16 @@ .editorError { padding: 12px 16px; - font-size: 12px; - color: var(--dsw-static-red-500, #ef4444); + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-state-error-primary); } .editorBanner { flex: none; padding: 4px 12px; - font-size: 11px; - color: var(--dsw-static-amber-600, #dd8629); - background: var(--dsw-static-amber-100, #fef5e7); + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); } .editorTextarea { @@ -479,9 +527,7 @@ resize: none; background: transparent; color: var(--dsw-alias-label-primary); - font-family: var(--dsw-font-mono, 'SF Mono', Menlo, Consolas, monospace); - font-size: 12px; - line-height: 1.55; + font: var(--dsw-font-markdown-code-block-small); tab-size: 2; white-space: pre; } @@ -507,7 +553,7 @@ min-height: 0; overflow-y: auto; padding: 10px 14px; - font-size: 13px; + font: var(--dsw-font-xs-13); } /* ── Terminal ──────────────────────────────────────────────────────────── */ @@ -534,9 +580,9 @@ .terminalBanner { flex: none; padding: 3px 10px; - font-size: 11px; - color: var(--dsw-static-amber-600, #dd8629); - background: var(--dsw-static-amber-100, #fef5e7); + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); } /* ── Git ───────────────────────────────────────────────────────────────── */ @@ -566,7 +612,7 @@ border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); - font-size: 12px; + font: var(--dsw-font-xxs-12); } .gitSection { @@ -578,8 +624,7 @@ align-items: center; justify-content: space-between; padding: 6px 12px 4px; - font-size: 11px; - font-weight: 600; + font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-label-tertiary); text-transform: uppercase; } @@ -587,7 +632,7 @@ .gitLink { border: none; background: transparent; - font-size: 11px; + font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-brand-primary); cursor: pointer; padding: 0; @@ -638,8 +683,7 @@ align-items: center; justify-content: center; border-radius: 4px; - font-size: 10px; - font-weight: 700; + font: var(--dsw-font-xxxs-strong-11); background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } @@ -650,27 +694,27 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-primary); } .gitEmpty { padding: 4px 12px 8px; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); } .gitPlaceholder { padding: 16px; - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); text-align: center; } .gitError { padding: 8px 12px; - font-size: 12px; - color: var(--dsw-static-red-500, #ef4444); + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-state-error-primary); white-space: pre-wrap; } @@ -699,8 +743,8 @@ border: none; border-radius: 6px; background: var(--dsw-alias-button-primary-fill); - color: var(--dsw-alias-label-primary-inverted, #fff); - font-size: 12px; + color: var(--dsw-alias-label-primary-inverted); + font: var(--dsw-font-xxs-strong-12); cursor: pointer; } @@ -718,12 +762,12 @@ align-items: baseline; gap: 8px; padding: 3px 12px; - font-size: 12px; + font: var(--dsw-font-xxs-12); } .gitLogHash { flex: none; - font-family: var(--dsw-font-mono, 'SF Mono', Menlo, Consolas, monospace); + font: var(--dsw-font-markdown-code-block-small); color: var(--dsw-alias-label-tertiary); } @@ -747,7 +791,7 @@ } .producedLabel { - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); } @@ -761,7 +805,7 @@ border-radius: 999px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); - font-size: 12px; + font: var(--dsw-font-xxs-12); cursor: pointer; overflow: hidden; } @@ -778,6 +822,6 @@ } .producedMore { - font-size: 12px; + font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); } diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index 20a819c..abd8bcb 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -1,14 +1,19 @@ /** * The split-pane workbench: renders the recursive split tree. A split lays * children out row- or column-wise with draggable dividers (fractional - * sizes); a leaf renders its tab strip plus the active tab's content and - * accepts tab drops. The tree and all operations live in state.ts — this - * file is pure presentation over them. + * sizes); a leaf renders its tab strip plus the active tab's content. + * + * Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab + * over a pane, a drop overlay shows five zones — four edges (left/right/up/ + * down) that split the pane with the tab in a fresh leaf, and the center + * that merges the tab into the pane. The tree and all operations live in + * state.ts; this file is pure presentation over them. */ import { Fragment, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import type { SidebarState, SidebarTab, SplitNode } from './state.ts' +import type { DropZone } from './state.ts' import { t } from './locales.ts' import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx' import css from './sidebar.module.css' @@ -19,8 +24,10 @@ export interface WorkbenchActions { activateTab: (paneId: string, tabId: string) => void /** Make a pane the target of newly opened tabs (click focus). */ focusPane: (paneId: string) => void - splitPane: (dir: 'row' | 'col') => void - moveTab: (payload: TabDragPayload, toPane: string, before: string | null) => void + /** VSCode drag gesture: edge → split the target pane, center → merge. */ + moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void + /** Reorder within a pane (drop onto another tab inserts before it). */ + moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void resizeSplit: (splitId: string, index: number, deltaFrac: number) => void } @@ -59,7 +66,20 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v ) } -/** A leaf: tab strip + active content + drop target for tabs. */ +/** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */ +function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone { + const rect = pane.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return 'center' + const x = (event.clientX - rect.left) / rect.width + const y = (event.clientY - rect.top) / rect.height + if (x < 0.25) return 'left' + if (x > 0.75) return 'right' + if (y < 0.25) return 'up' + if (y > 0.75) return 'down' + return 'center' +} + +/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */ function LeafView(props: { leaf: { id: string; tabs: SidebarTab[]; active: string | null } newTabOptions: NewTabOption[] @@ -68,24 +88,32 @@ function LeafView(props: { renderTab: (tab: SidebarTab) => ReactNode }) { const { leaf, newTabOptions, actions, onNewTab, renderTab } = props - const [dropTarget, setDropTarget] = useState(false) + const [dropZone, setDropZone] = useState(null) const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1] return (
{ actions.focusPane(leaf.id) }} onDragOver={(event) => { event.preventDefault() - setDropTarget(true) + const zone = zoneAt(event, event.currentTarget) + setDropZone(zone) + }} + onDragLeave={(event) => { + // Only clear when the pointer left the pane entirely (not onto a child). + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setDropZone(null) + } }} - onDragLeave={() => { setDropTarget(false) }} onDrop={(event) => { event.preventDefault() - setDropTarget(false) + const zone = dropZone ?? zoneAt(event, event.currentTarget) + setDropZone(null) const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab')) - if (payload !== null) actions.moveTab(payload, leaf.id, null) + if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone) }} > + {dropZone !== null &&
} {leaf.tabs.length > 0 ? ( <> { actions.activateTab(leaf.id, tabId) }} onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }} - onSplit={(dir) => { actions.splitPane(dir) }} onNewTab={onNewTab} newTabOptions={newTabOptions} - onDropTab={(payload, before) => { actions.moveTab(payload, leaf.id, before) }} + onDropTab={(payload, before) => { + if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center') + else actions.moveTabBefore(payload, leaf.id, before) + }} />
{activeTab !== undefined ? renderTab(activeTab) : null}
diff --git a/src/client/state.ts b/src/client/state.ts index 3d63c5f..214eebd 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -137,6 +137,77 @@ export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col') }) } +/** + * Split a leaf by inserting a fresh leaf holding `tab` beside it — the + * VSCode drag-to-edge gesture. `dir` is the split direction ('row' for + * left/right, 'col' for up/down); `front` places the new leaf first (left/ + * up) or second (right/down). + * @returns the new tree plus the fresh leaf's id (the drop's active pane). + */ +export function insertLeafAt( + node: SplitNode, + paneId: string, + dir: 'row' | 'col', + tab: SidebarTab, + front: boolean, +): { node: SplitNode; leafId: string } { + const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id } + const leafId = fresh.id + const next = mapLeaf(node, paneId, (leaf) => { + const target: SidebarLeaf = { ...leaf } + const split: SidebarSplit = { + kind: 'split', + id: uid('split'), + dir, + sizes: [0.5, 0.5], + children: front ? [fresh, target] : [target, fresh], + } + Object.assign(leaf, split) + }) + return { node: next, leafId } +} + +/** Where a tab drop lands on a pane: an edge creates a split, center merges. */ +export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center' + +/** + * The VSCode drag gesture: move a tab out of its pane and either merge it + * into the target pane (center) or split the target pane with the tab in a + * fresh leaf (edge). The source pane collapses when it empties. + */ +export function moveTabToEdge( + state: SidebarState, + fromPane: string, + tabId: string, + toPane: string, + zone: DropZone, +): SidebarState { + if (fromPane === toPane && zone === 'center') { + // Dropped back onto its own pane's center: reorder to the end. + return moveTab(state, fromPane, tabId, toPane, -1) + } + const source = leafWithTab(state.splits, tabId) + if (source === undefined) return state + const tab = source.tabs.find(candidate => candidate.id === tabId)! + let emptied = false + let splits = mapLeaf(state.splits, source.id, (leaf) => { + leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId) + if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null + if (leaf.tabs.length === 0) emptied = true + }) + if (emptied) splits = removeLeafAt(splits, source.id) + if (zone === 'center') { + splits = mapLeaf(splits, toPane, (leaf) => { + leaf.tabs = [...leaf.tabs, tab] + leaf.active = tab.id + }) + return { ...state, splits, activePane: toPane } + } + const dir = zone === 'left' || zone === 'right' ? 'row' : 'col' + const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up') + return { ...state, splits: result.node, activePane: result.leafId } +} + /** * Remove a leaf from the tree. A split left with one child promotes that * child; removing the last leaf yields an empty leaf. diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index c2cf40b..f303e88 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts' import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { - activateTab, closeTab, makeDefaultState, moveTab, openTab, resizeSplit, - splitPane, toggleExpanded, type SidebarState, type SplitNode, + activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab, + resizeSplit, splitPane, toggleExpanded, type SidebarState, type SplitNode, } from '../src/client/state.ts' import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts' @@ -90,6 +90,49 @@ describe('sidebar state', () => { expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab]) }) + it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => { + let s = state() + s = splitPane(s, 'row') + const split = s.splits as Extract + const paneA = split.children[0] as { id: string; tabs: { id: string }[] } + const paneB = split.children[1] as { id: string; tabs: { id: string }[] } + const tabId = paneA.tabs[0]!.id + // 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。 + s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' }) + s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right') + const after = s.splits as Extract + // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 + const bSplit = after.children.find(child => child.kind === 'split') as Extract | undefined + expect(bSplit).toBeDefined() + expect(bSplit!.dir).toBe('row') + const newLeaf = bSplit!.children[1] as { tabs: { id: string }[] } + expect(newLeaf.tabs.map(t => t.id)).toContain(tabId) + }) + + it('dragging a tab to a pane center merges it into the pane', () => { + let s = state() + s = splitPane(s, 'col') + const split = s.splits as Extract + const paneA = split.children[0] as { id: string; tabs: { id: string }[] } + const paneB = split.children[1] as { id: string; tabs: { id: string }[] } + const tabId = paneA.tabs[0]!.id + s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center') + // paneA 空了被移除,树退化为 paneB(含 tab)。 + expect(s.splits.kind).toBe('leaf') + expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId]) + }) + + it('dragging a tab back onto its own pane center reorders it', () => { + let s = state() + s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' }) + const leaf = s.splits as { id: string; tabs: { id: string }[] } + const first = leaf.tabs[0]!.id + s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center') + const after = s.splits as { tabs: { id: string }[] } + expect(after.tabs[after.tabs.length - 1]!.id).toBe(first) + expect(after.tabs).toHaveLength(2) + }) + it('closing the last tab removes the pane (promotes the sibling)', () => { let s = state() s = splitPane(s, 'col')