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:
Menghuan1918
2026-08-08 10:35:47 +08:00
parent 63e2988ef1
commit 58b4bd71ba
8 changed files with 282 additions and 110 deletions
+16 -16
View File
@@ -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 && (
<div className={css.toggleRail}>
<Tooltip label={t('openExplorer')} side="bottom" delayMs={500}>
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('openExplorer')}
aria-label={t('expand')}
onClick={() => { sidebarStore.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
@@ -222,11 +222,11 @@ export function Sidebar(props: { ctx: Context }) {
<button
type="button"
className={css.iconButton}
aria-label={t('close')}
title={t('close')}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { sidebarStore.reduce(togglePanel) }}
>
<IconCloseOutline16 />
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.panelBody}>
+3 -24
View File
@@ -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: {
))}
</div>
<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
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
+4
View File
@@ -16,6 +16,8 @@ const zh = {
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
close: '关闭',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
refresh: '刷新',
save: '保存',
saved: '已保存',
@@ -71,6 +73,8 @@ const en: Record<keyof typeof zh, string> = {
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
close: 'Close',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
+97 -53
View File
@@ -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);
}
+44 -14
View File
@@ -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<DropZone | null>(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
return (
<div
className={clsx(css.pane, dropTarget && css.paneDrop)}
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
onPointerDown={() => { 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 && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
{leaf.tabs.length > 0 ? (
<>
<TabBar
@@ -94,10 +122,12 @@ function LeafView(props: {
active={leaf.active}
onActivate={(tabId) => { 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)
}}
/>
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
</>
+71
View File
@@ -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.