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:
+16
-16
@@ -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
@@ -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) }}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user