sidebar: VSCode-style drag-to-edge splitting, collapse instead of close, theme-compliant tokens
- Split panes by dragging a tab to a pane edge (left/right/up/down zones with a drop overlay) or into the center to merge; the toolbar split buttons are gone (split-pane.tsx, state.ts moveTabToEdge/insertLeafAt). - The header close button is now a collapse button; the expand button on the right edge stays visible whenever the panel is collapsed, so the sidebar can always be reopened (Sidebar.tsx, locales collapse/expand). - Styles now follow the DSH web-styling rules exactly: --dsw-alias-* semantic colors (state-error/warn tokens), --dsw-font-* typography roles (size + line-height paired), --dsw-shadow-lv* elevation, no color literals or static palette values (sidebar.module.css). - Tests: +3 for moveTabToEdge (edge split, center merge, self reorder).
This commit is contained in:
@@ -9,7 +9,8 @@ control panel — **isolated per conversation session**.
|
|||||||
- Editor: 文本编辑(脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览
|
- Editor: 文本编辑(脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览
|
||||||
- Terminal: xterm.js + node-pty(每会话最多 3 个,进程跨刷新存活)
|
- Terminal: xterm.js + node-pty(每会话最多 3 个,进程跨刷新存活)
|
||||||
- Git: status / diff / stage / commit / branch / history(基础集)
|
- Git: status / diff / stage / commit / branch / history(基础集)
|
||||||
- 分栏工作台: 上下左右递归分栏、分隔线拖拽、Tab 拖拽移动、面板宽度拖拽
|
- 分栏工作台: VSCode 式拖拽分栏(拖 Tab 到分栏边缘即左右/上下拆分,拖到中间合并)、
|
||||||
|
分隔线拖拽、面板宽度拖拽;折叠后右缘展开按钮常驻
|
||||||
- Tab 栏: 最大宽度 160px、横向滚动、`+` 菜单新建 Tab
|
- Tab 栏: 最大宽度 160px、横向滚动、`+` 菜单新建 Tab
|
||||||
- 拦截: 对话"产出文件"行点击改为在侧边栏打开
|
- 拦截: 对话"产出文件"行点击改为在侧边栏打开
|
||||||
- 样式完全复用 `--dsw-*` 主题变量与 `@deepseek-ai/dsh-client-ui-primitives`
|
- 样式完全复用 `--dsw-*` 主题变量与 `@deepseek-ai/dsh-client-ui-primitives`
|
||||||
|
|||||||
+16
-16
@@ -12,12 +12,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
import clsx from 'clsx'
|
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 type { Context } from '../context-types.ts'
|
||||||
import {
|
import {
|
||||||
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, openTab, resizeSplit,
|
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
|
||||||
sidebarStore, splitPane, setWidth, toggleExpanded, togglePanel,
|
resizeSplit, sidebarStore, setWidth, toggleExpanded, togglePanel,
|
||||||
TERMINAL_LIMIT, type SidebarState, type SidebarTab, type SplitNode,
|
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarTab, type SplitNode,
|
||||||
} from './state.ts'
|
} from './state.ts'
|
||||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||||
import type { NewTabOption } from './TabBar.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 })) },
|
focusPane: (paneId) => { sidebarStore.reduce(s => ({ ...s, activePane: paneId })) },
|
||||||
splitPane: (dir) => { sidebarStore.reduce(s => splitPane(s, dir)) },
|
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
||||||
moveTab: (payload: TabDragPayload, toPane: string, before: string | null) => {
|
sidebarStore.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
|
||||||
|
},
|
||||||
|
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
|
||||||
sidebarStore.reduce((s) => {
|
sidebarStore.reduce((s) => {
|
||||||
let index = -1
|
let index = -1
|
||||||
if (before !== null) {
|
const source = leafWithTab(s.splits, beforeTabId)
|
||||||
const source = leafWithTab(s.splits, before)
|
if (source !== undefined && source.id === toPane) {
|
||||||
if (source !== undefined && source.id === toPane) {
|
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
|
||||||
index = source.tabs.findIndex(tab => tab.id === before)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
|
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
|
||||||
})
|
})
|
||||||
@@ -181,11 +181,11 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
<>
|
<>
|
||||||
{!state.panelOpen && (
|
{!state.panelOpen && (
|
||||||
<div className={css.toggleRail}>
|
<div className={css.toggleRail}>
|
||||||
<Tooltip label={t('openExplorer')} side="bottom" delayMs={500}>
|
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.toggleButton}
|
className={css.toggleButton}
|
||||||
aria-label={t('openExplorer')}
|
aria-label={t('expand')}
|
||||||
onClick={() => { sidebarStore.reduce(togglePanel) }}
|
onClick={() => { sidebarStore.reduce(togglePanel) }}
|
||||||
>
|
>
|
||||||
<IconPanelLeftOutline16 size={16} />
|
<IconPanelLeftOutline16 size={16} />
|
||||||
@@ -222,11 +222,11 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.iconButton}
|
className={css.iconButton}
|
||||||
aria-label={t('close')}
|
aria-label={t('collapse')}
|
||||||
title={t('close')}
|
title={t('collapse')}
|
||||||
onClick={() => { sidebarStore.reduce(togglePanel) }}
|
onClick={() => { sidebarStore.reduce(togglePanel) }}
|
||||||
>
|
>
|
||||||
<IconCloseOutline16 />
|
<IconChevronRightOutline14 />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.panelBody}>
|
<div className={css.panelBody}>
|
||||||
|
|||||||
+3
-24
@@ -8,11 +8,10 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
IconCloseFill14, IconPlusOutline16, Menu,
|
||||||
IconChevronUpOutline14, IconCloseFill14, IconPlusOutline16, Menu,
|
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { SidebarTab } from './state.ts'
|
import type { SidebarTab } from './state.ts'
|
||||||
import { t, type CopyKey } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
/** One + menu option. */
|
/** 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: {
|
export function TabBar(props: {
|
||||||
paneId: string
|
paneId: string
|
||||||
tabs: SidebarTab[]
|
tabs: SidebarTab[]
|
||||||
active: string | null
|
active: string | null
|
||||||
onActivate: (tabId: string) => void
|
onActivate: (tabId: string) => void
|
||||||
onClose: (tabId: string) => void
|
onClose: (tabId: string) => void
|
||||||
onSplit: (dir: 'row' | 'col') => void
|
|
||||||
onNewTab: (optionId: string) => void
|
onNewTab: (optionId: string) => void
|
||||||
newTabOptions: NewTabOption[]
|
newTabOptions: NewTabOption[]
|
||||||
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
||||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
paneId, tabs, active, onActivate, onClose, onSplit, onNewTab, newTabOptions, onDropTab,
|
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab,
|
||||||
} = props
|
} = props
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
@@ -122,18 +113,6 @@ export function TabBar(props: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={css.tabBarActions}>
|
<div className={css.tabBarActions}>
|
||||||
{SPLIT_DIRS.map(({ dir, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={dir}
|
|
||||||
type="button"
|
|
||||||
className={css.tabBarButton}
|
|
||||||
aria-label={t(label)}
|
|
||||||
title={t(label)}
|
|
||||||
onClick={() => { onSplit(dir === 'left' || dir === 'right' ? 'row' : 'col') }}
|
|
||||||
>
|
|
||||||
<Icon />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<Menu
|
<Menu
|
||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
onClose={() => { setMenuOpen(false) }}
|
onClose={() => { setMenuOpen(false) }}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const zh = {
|
|||||||
newTerminal: '新终端',
|
newTerminal: '新终端',
|
||||||
terminalLimit: '终端数量已达上限 (3)',
|
terminalLimit: '终端数量已达上限 (3)',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
|
collapse: '折叠侧边栏',
|
||||||
|
expand: '展开侧边栏',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saved: '已保存',
|
saved: '已保存',
|
||||||
@@ -71,6 +73,8 @@ const en: Record<keyof typeof zh, string> = {
|
|||||||
newTerminal: 'New terminal',
|
newTerminal: 'New terminal',
|
||||||
terminalLimit: 'Terminal limit reached (3)',
|
terminalLimit: 'Terminal limit reached (3)',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
|
collapse: 'Collapse sidebar',
|
||||||
|
expand: 'Expand sidebar',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saved: 'Saved',
|
saved: 'Saved',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Sidebar styles: everything rides the DSH theme tokens (--dsw-alias-*,
|
* Sidebar styles. Per the DSH web-styling rules every visual value rides the
|
||||||
* --dsw-static-*, --ds-transition-*) so the plugin matches the host UI in
|
* theme: --dsw-alias-* semantic tokens for colors, --dsw-font-* typography
|
||||||
* both light and dark themes, plus a few absolute z-indexes for the
|
* roles (size paired with line-height), --dsw-shadow-* for elevation, and
|
||||||
* document-level overlay (below the app's modal layer).
|
* --ds-* for motion. No static palette values, no color literals, no theme
|
||||||
|
* selectors — light/dark adaptation is the theme package's job.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
||||||
@@ -21,14 +22,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 26px;
|
||||||
height: 56px;
|
height: 64px;
|
||||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-radius: 10px 0 0 10px;
|
border-radius: 10px 0 0 10px;
|
||||||
background: var(--dsw-alias-button-floating-fill);
|
background: var(--dsw-alias-button-floating-fill);
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-shadow: var(--dsw-shadow-lv1);
|
||||||
transition:
|
transition:
|
||||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||||
color 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;
|
flex-direction: column;
|
||||||
background: var(--dsw-alias-bg-layer-1);
|
background: var(--dsw-alias-bg-layer-1);
|
||||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
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 {
|
.panelResize {
|
||||||
@@ -83,8 +85,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panelTitle {
|
.panelTitle {
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -178,6 +179,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pane {
|
.pane {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -191,6 +193,53 @@
|
|||||||
outline-offset: -1px;
|
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 {
|
.paneContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -206,7 +255,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +295,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 0 4px 0 10px;
|
padding: 0 4px 0 10px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
border-right: 1px solid var(--dsw-alias-border-l1);
|
border-right: 1px solid var(--dsw-alias-border-l1);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -338,8 +387,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.explorerRoot {
|
.explorerRoot {
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -362,7 +410,7 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -374,7 +422,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.explorerDir {
|
.explorerDir {
|
||||||
font-weight: 500;
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.explorerHidden {
|
.explorerHidden {
|
||||||
@@ -387,13 +435,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.explorerError {
|
.explorerError {
|
||||||
color: var(--dsw-static-red-500, #ef4444);
|
font: var(--dsw-font-xxs-12);
|
||||||
|
color: var(--dsw-alias-state-error-primary);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explorerEmpty {
|
.explorerEmpty {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -419,8 +468,7 @@
|
|||||||
.editorTitle {
|
.editorTitle {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -428,19 +476,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editorStatus {
|
.editorStatus {
|
||||||
font-size: 11px;
|
font: var(--dsw-font-xxxs-11);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorStatusError {
|
.editorStatusError {
|
||||||
color: var(--dsw-static-red-500, #ef4444);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dirtyDot {
|
.dirtyDot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dsw-static-amber-500, #f59e0b);
|
background: var(--dsw-alias-state-warn-primary);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +497,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -457,16 +505,16 @@
|
|||||||
|
|
||||||
.editorError {
|
.editorError {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-static-red-500, #ef4444);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorBanner {
|
.editorBanner {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 11px;
|
font: var(--dsw-font-xxxs-11);
|
||||||
color: var(--dsw-static-amber-600, #dd8629);
|
color: var(--dsw-alias-state-warn-label);
|
||||||
background: var(--dsw-static-amber-100, #fef5e7);
|
background: var(--dsw-alias-state-warn-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorTextarea {
|
.editorTextarea {
|
||||||
@@ -479,9 +527,7 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
font-family: var(--dsw-font-mono, 'SF Mono', Menlo, Consolas, monospace);
|
font: var(--dsw-font-markdown-code-block-small);
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@@ -507,7 +553,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 13px;
|
font: var(--dsw-font-xs-13);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
||||||
@@ -534,9 +580,9 @@
|
|||||||
.terminalBanner {
|
.terminalBanner {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
font-size: 11px;
|
font: var(--dsw-font-xxxs-11);
|
||||||
color: var(--dsw-static-amber-600, #dd8629);
|
color: var(--dsw-alias-state-warn-label);
|
||||||
background: var(--dsw-static-amber-100, #fef5e7);
|
background: var(--dsw-alias-state-warn-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Git ───────────────────────────────────────────────────────────────── */
|
/* ── Git ───────────────────────────────────────────────────────────────── */
|
||||||
@@ -566,7 +612,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--dsw-alias-bg-base);
|
background: var(--dsw-alias-bg-base);
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitSection {
|
.gitSection {
|
||||||
@@ -578,8 +624,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 6px 12px 4px;
|
padding: 6px 12px 4px;
|
||||||
font-size: 11px;
|
font: var(--dsw-font-xxxs-strong-11);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -587,7 +632,7 @@
|
|||||||
.gitLink {
|
.gitLink {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 11px;
|
font: var(--dsw-font-xxxs-11);
|
||||||
color: var(--dsw-alias-brand-primary);
|
color: var(--dsw-alias-brand-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -638,8 +683,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font: var(--dsw-font-xxxs-strong-11);
|
||||||
font-weight: 700;
|
|
||||||
background: var(--dsw-alias-interactive-bg-hover);
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
}
|
}
|
||||||
@@ -650,27 +694,27 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitEmpty {
|
.gitEmpty {
|
||||||
padding: 4px 12px 8px;
|
padding: 4px 12px 8px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitPlaceholder {
|
.gitPlaceholder {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitError {
|
.gitError {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-static-red-500, #ef4444);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,8 +743,8 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--dsw-alias-button-primary-fill);
|
background: var(--dsw-alias-button-primary-fill);
|
||||||
color: var(--dsw-alias-label-primary-inverted, #fff);
|
color: var(--dsw-alias-label-primary-inverted);
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,12 +762,12 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 3px 12px;
|
padding: 3px 12px;
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitLogHash {
|
.gitLogHash {
|
||||||
flex: none;
|
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);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,7 +791,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.producedLabel {
|
.producedLabel {
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +805,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--dsw-alias-bg-layer-2);
|
background: var(--dsw-alias-bg-layer-2);
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -778,6 +822,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.producedMore {
|
.producedMore {
|
||||||
font-size: 12px;
|
font: var(--dsw-font-xxs-12);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-14
@@ -1,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* The split-pane workbench: renders the recursive split tree. A split lays
|
* The split-pane workbench: renders the recursive split tree. A split lays
|
||||||
* children out row- or column-wise with draggable dividers (fractional
|
* children out row- or column-wise with draggable dividers (fractional
|
||||||
* sizes); a leaf renders its tab strip plus the active tab's content and
|
* sizes); a leaf renders its tab strip plus the active tab's content.
|
||||||
* accepts tab drops. The tree and all operations live in state.ts — this
|
*
|
||||||
* file is pure presentation over them.
|
* 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 { Fragment, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||||
|
import type { DropZone } from './state.ts'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
@@ -19,8 +24,10 @@ export interface WorkbenchActions {
|
|||||||
activateTab: (paneId: string, tabId: string) => void
|
activateTab: (paneId: string, tabId: string) => void
|
||||||
/** Make a pane the target of newly opened tabs (click focus). */
|
/** Make a pane the target of newly opened tabs (click focus). */
|
||||||
focusPane: (paneId: string) => void
|
focusPane: (paneId: string) => void
|
||||||
splitPane: (dir: 'row' | 'col') => void
|
/** VSCode drag gesture: edge → split the target pane, center → merge. */
|
||||||
moveTab: (payload: TabDragPayload, toPane: string, before: string | null) => void
|
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
|
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: {
|
function LeafView(props: {
|
||||||
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
|
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
|
||||||
newTabOptions: NewTabOption[]
|
newTabOptions: NewTabOption[]
|
||||||
@@ -68,24 +88,32 @@ function LeafView(props: {
|
|||||||
renderTab: (tab: SidebarTab) => ReactNode
|
renderTab: (tab: SidebarTab) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
||||||
const [dropTarget, setDropTarget] = useState(false)
|
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(css.pane, dropTarget && css.paneDrop)}
|
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||||
onPointerDown={() => { actions.focusPane(leaf.id) }}
|
onPointerDown={() => { actions.focusPane(leaf.id) }}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
event.preventDefault()
|
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) => {
|
onDrop={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setDropTarget(false)
|
const zone = dropZone ?? zoneAt(event, event.currentTarget)
|
||||||
|
setDropZone(null)
|
||||||
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
|
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 && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
|
||||||
{leaf.tabs.length > 0 ? (
|
{leaf.tabs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<TabBar
|
<TabBar
|
||||||
@@ -94,10 +122,12 @@ function LeafView(props: {
|
|||||||
active={leaf.active}
|
active={leaf.active}
|
||||||
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
|
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
|
||||||
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
|
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
|
||||||
onSplit={(dir) => { actions.splitPane(dir) }}
|
|
||||||
onNewTab={onNewTab}
|
onNewTab={onNewTab}
|
||||||
newTabOptions={newTabOptions}
|
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)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
|
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
* Remove a leaf from the tree. A split left with one child promotes that
|
||||||
* child; removing the last leaf yields an empty leaf.
|
* child; removing the last leaf yields an empty leaf.
|
||||||
|
|||||||
+45
-2
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||||
import {
|
import {
|
||||||
activateTab, closeTab, makeDefaultState, moveTab, openTab, resizeSplit,
|
activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab,
|
||||||
splitPane, toggleExpanded, type SidebarState, type SplitNode,
|
resizeSplit, splitPane, toggleExpanded, type SidebarState, type SplitNode,
|
||||||
} from '../src/client/state.ts'
|
} from '../src/client/state.ts'
|
||||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.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])
|
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<SplitNode, { kind: 'split' }>
|
||||||
|
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<SplitNode, { kind: 'split' }>
|
||||||
|
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
|
||||||
|
const bSplit = after.children.find(child => child.kind === 'split') as Extract<SplitNode, { kind: 'split' }> | 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<SplitNode, { kind: 'split' }>
|
||||||
|
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)', () => {
|
it('closing the last tab removes the pane (promotes the sibling)', () => {
|
||||||
let s = state()
|
let s = state()
|
||||||
s = splitPane(s, 'col')
|
s = splitPane(s, 'col')
|
||||||
|
|||||||
Reference in New Issue
Block a user