feat(ui): 自由窗口——tab 拖出侧边栏成为可移动/缩放/置顶的浮动窗口 (v0.16.0)
- 拖出:标签栏 tab 拖到主会话列(HTML5 DnD 检测 + 虚线提示浮层)或右键菜单 「移动到自由窗口」(右/底面板共用 TabBar),tab 从 pane 移动进 SidebarState.floats - 窗口(FreeWindow,渲染于 [data-dsh-panel-host] z-42):头部 pointer 拖动 (rAF 直写 + 松手提交)、拖到 pane 高亮停靠(center 合并)、SE 角缩放、 点击置顶、头部右键(回到侧边栏/关闭)、X = closeTab 走正常生命周期 - 服务语义:openTab dedupe/id 聚焦浮动 tab = 置顶窗口(不展开面板); closeTab/activateTab 兼顾 floats;agent 终端 reconcile 覆盖浮窗; features 单调新增 'floatWindows',版本 0.16.0 - 持久化:floats 随会话 localStorage,sanitizeState 宽容校验 + 几何钳制, uid 播种覆盖 float 前缀;i18n 三键 19 语言同步 - 关键修复(e2e 发现):portal 覆盖层(头部 Menu)的合成事件沿 React 树 冒泡回拖拽表面,劫持菜单行点击——拖拽起点加 DOM 同源守卫(jsdom 不复现) - e2e-mount.sh:缺省 tarball 改取 mtime 最新(旧字典序选择会把冒烟挂到 过期产物);新增 tests/e2e/float-window.e2e.ts 双用例
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* One free window: a tab dragged out of the workbench floating over the
|
||||
* conversation area at viewport coordinates (rendered inside the panel host,
|
||||
* so desktop-shell transforms can never hijack its fixed containing block).
|
||||
*
|
||||
* The header drags the window with the panel-resize pattern — pointer
|
||||
* capture + per-frame direct DOM writes + a store commit on release — and
|
||||
* doubling as the DOCK-BACK gesture: while the pointer is over a workbench
|
||||
* pane ([data-dsh-pane], either panel), that pane highlights live and
|
||||
* releasing docks the tab into it (center merge); releasing anywhere else
|
||||
* just moves the window. The SE corner resizes, any press raises (the
|
||||
* floats array's order is the stacking order), the header right-click menu
|
||||
* and the X button dock / close. The tab content reuses the regular tab
|
||||
* renderer, so every tab type (terminal, editor, plugin tabs) floats
|
||||
* unchanged.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { FloatWindow, SidebarTab } from './state.ts'
|
||||
import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** A drag's mutable bookkeeping (start geometry + the last applied values). */
|
||||
interface DragState {
|
||||
/** Which handle owns the drag (the abort path reads it from this ref —
|
||||
* the React `dragging` state lags a render behind pointerdown). */
|
||||
mode: 'move' | 'resize'
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
startX: number
|
||||
startY: number
|
||||
startW: number
|
||||
startH: number
|
||||
/** Last geometry actually written to the DOM (the cancel-path fallback). */
|
||||
applied: { x: number; y: number; w: number; h: number }
|
||||
/** Settled after the final pointer event (the up position wins over the
|
||||
* rAF-pending value — a fast flick coalesces its tail into the up event). */
|
||||
committed: boolean
|
||||
}
|
||||
|
||||
/** The pane under a viewport point, if any (rect hit-test; the dragged
|
||||
* window itself is not a pane, so it cannot shadow the targets). */
|
||||
function paneAt(x: number, y: number): HTMLElement | null {
|
||||
for (const pane of document.querySelectorAll<HTMLElement>('[data-dsh-pane]')) {
|
||||
const rect = pane.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) continue
|
||||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Pointer-capture helpers tolerant of environments without the API (jsdom
|
||||
* lacks setPointerCapture — component tests dispatch plain MouseEvents, so
|
||||
* the optional calls keep them driving the drag; real browsers always have
|
||||
* it and a missing pointerId can never occur there). */
|
||||
const capturePointer = (element: HTMLElement, pointerId: number): void => {
|
||||
element.setPointerCapture?.(pointerId)
|
||||
}
|
||||
|
||||
const releasePointer = (element: HTMLElement, pointerId: number): void => {
|
||||
element.releasePointerCapture?.(pointerId)
|
||||
}
|
||||
|
||||
/** Whether the element holds the pointer (assumed true without the API). */
|
||||
const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
|
||||
return element.hasPointerCapture?.(pointerId) !== false
|
||||
}
|
||||
|
||||
export function FreeWindow(props: {
|
||||
float: FloatWindow
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
onRaise: () => void
|
||||
onMove: (x: number, y: number) => void
|
||||
onResize: (w: number, h: number) => void
|
||||
/** Dock the tab back; the pane id, or null for the active pane. */
|
||||
onDock: (paneId: string | null) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
// The pane currently highlighted as the dock target (a direct DOM write:
|
||||
// per-frame React state would re-render every mounted tab at drag cadence).
|
||||
const dockTargetRef = useRef<HTMLElement | null>(null)
|
||||
// rAF coalescing: pointermove outruns the display, one write per frame.
|
||||
const frameRef = useRef<number | null>(null)
|
||||
const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||
const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
|
||||
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
|
||||
}, [])
|
||||
|
||||
/** Apply the pending geometry to the DOM (one rAF per frame at most). */
|
||||
const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
|
||||
pendingRef.current = geo
|
||||
if (frameRef.current !== null) return
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null
|
||||
const pending = pendingRef.current
|
||||
const drag = dragRef.current
|
||||
const root = rootRef.current
|
||||
if (pending === null || drag === null || root === null) return
|
||||
drag.applied = pending
|
||||
root.style.left = `${pending.x}px`
|
||||
root.style.top = `${pending.y}px`
|
||||
root.style.width = `${pending.w}px`
|
||||
root.style.height = `${pending.h}px`
|
||||
})
|
||||
}
|
||||
|
||||
/** Flush the pending frame synchronously (the release path's last write). */
|
||||
const flushNow = (): void => {
|
||||
const pending = pendingRef.current
|
||||
const drag = dragRef.current
|
||||
const root = rootRef.current
|
||||
if (pending === null || drag === null || root === null) return
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
pendingRef.current = null
|
||||
drag.applied = pending
|
||||
root.style.left = `${pending.x}px`
|
||||
root.style.top = `${pending.y}px`
|
||||
root.style.width = `${pending.w}px`
|
||||
root.style.height = `${pending.h}px`
|
||||
}
|
||||
|
||||
const clearDockHighlight = (): void => {
|
||||
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
|
||||
dockTargetRef.current = null
|
||||
}
|
||||
|
||||
/** Release a drag: `pane` (when set) docks instead of moving. */
|
||||
const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.committed) return
|
||||
drag.committed = true
|
||||
dragRef.current = null
|
||||
setDragging(null)
|
||||
const target = pane ?? dockTargetRef.current
|
||||
clearDockHighlight()
|
||||
if (mode === 'move' && target !== null) {
|
||||
onDock(target.getAttribute('data-dsh-pane'))
|
||||
} else if (mode === 'move') {
|
||||
onMove(geo.x, geo.y)
|
||||
} else {
|
||||
onResize(geo.w, geo.h)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel-path settle (pointercancel / lostpointercapture): the last
|
||||
* APPLIED geometry is the user-visible truth — commit it, never roll back
|
||||
* (the panel drags' issue-#247 semantics). The mode comes from the ref —
|
||||
* the React `dragging` state can still be null when the cancel lands
|
||||
* before the pointerdown re-render commits. */
|
||||
const abortDrag = (): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.committed) return
|
||||
flushNow()
|
||||
finishDrag(drag.mode, drag.applied, null)
|
||||
}
|
||||
|
||||
const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
return {
|
||||
x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
|
||||
y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
|
||||
w: float.w,
|
||||
h: float.h,
|
||||
}
|
||||
}
|
||||
|
||||
const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
return {
|
||||
x: float.x,
|
||||
y: float.y,
|
||||
w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
|
||||
h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.floatWindow, dragging !== null && css.floatWindowDragging)}
|
||||
data-dsh-float-window
|
||||
data-dsh-float-id={float.id}
|
||||
style={{ left: float.x, top: float.y, width: float.w, height: float.h }}
|
||||
onPointerDown={() => { onRaise() }}
|
||||
>
|
||||
<div
|
||||
className={css.floatHeader}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
// Portaled overlays (the header Menu) live at document.body in the
|
||||
// DOM, yet their events still bubble through the React tree into
|
||||
// this handler — without the containment guard a menu-row click
|
||||
// would start a drag, preventDefault (killing the row's compat
|
||||
// mousedown) and capture the pointer (retargeting the release at
|
||||
// the header, so the row's click never fires). Buttons inside the
|
||||
// header (the close X) need the same exclusion: a capture here
|
||||
// swallows their click.
|
||||
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
|
||||
if (event.target instanceof Element && event.target.closest('button') !== null) return
|
||||
event.preventDefault()
|
||||
capturePointer(event.currentTarget, event.pointerId)
|
||||
dragRef.current = {
|
||||
mode: 'move',
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
startX: float.x,
|
||||
startY: float.y,
|
||||
startW: float.w,
|
||||
startH: float.h,
|
||||
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
|
||||
committed: false,
|
||||
}
|
||||
setDragging('move')
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
|
||||
scheduleApply(geo)
|
||||
const target = paneAt(event.clientX, event.clientY)
|
||||
if (target !== dockTargetRef.current) {
|
||||
clearDockHighlight()
|
||||
if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
|
||||
dockTargetRef.current = target
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
releasePointer(event.currentTarget, event.pointerId)
|
||||
flushNow()
|
||||
const geo = clampMove(
|
||||
dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
|
||||
dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
|
||||
)
|
||||
finishDrag('move', geo, paneAt(event.clientX, event.clientY))
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag() }}
|
||||
onLostPointerCapture={() => { abortDrag() }}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
setMenu({ x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(float.tab) ?? null}
|
||||
<span className={css.floatTitle} title={float.tab.title}>{float.tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.floatClose}
|
||||
aria-label={t('close')}
|
||||
onClick={(event) => { event.stopPropagation(); onClose() }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
<Menu
|
||||
open={menu !== null}
|
||||
onClose={() => { setMenu(null) }}
|
||||
items={[
|
||||
{ id: 'dock', label: t('dockToSidebar') },
|
||||
{ id: 'close', label: t('close') },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
setMenu(null)
|
||||
if (id === 'dock') onDock(null)
|
||||
else if (id === 'close') onClose()
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.floatContent}>
|
||||
{renderTab(float.tab, true, float.id)}
|
||||
</div>
|
||||
<div
|
||||
className={css.floatResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
|
||||
event.preventDefault()
|
||||
capturePointer(event.currentTarget, event.pointerId)
|
||||
dragRef.current = {
|
||||
mode: 'resize',
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
startX: float.x,
|
||||
startY: float.y,
|
||||
startW: float.w,
|
||||
startH: float.h,
|
||||
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
|
||||
committed: false,
|
||||
}
|
||||
setDragging('resize')
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
scheduleApply(clampResize(
|
||||
drag.startW + (event.clientX - drag.pointerX),
|
||||
drag.startH + (event.clientY - drag.pointerY),
|
||||
))
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
releasePointer(event.currentTarget, event.pointerId)
|
||||
flushNow()
|
||||
const geo = clampResize(
|
||||
dragRef.current.startW + (event.clientX - dragRef.current.pointerX),
|
||||
dragRef.current.startH + (event.clientY - dragRef.current.pointerY),
|
||||
)
|
||||
finishDrag('resize', geo, null)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag() }}
|
||||
onLostPointerCapture={() => { abortDrag() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+131
-6
@@ -28,16 +28,16 @@
|
||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||
* the right tree.
|
||||
*/
|
||||
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
||||
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarSessionList } from '../context-types.ts'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
|
||||
resizeFloat, resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
} from './state.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
@@ -48,7 +48,8 @@ import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
||||
import { getShellPreset } from './shell-presets.ts'
|
||||
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { FreeWindow } from './FreeWindow.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
@@ -86,7 +87,7 @@ const AUTO_OPEN_DEBOUNCE_MS = 500
|
||||
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
|
||||
* propagate exactly as before.
|
||||
*/
|
||||
const swallowOsFileDrag = (event: DragEvent): void => {
|
||||
const swallowOsFileDrag = (event: ReactDragEvent): void => {
|
||||
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -682,6 +683,85 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// above, this is the belt-and-braces retry for the open moment itself).
|
||||
}, [measureCenter, state?.bottomOpen])
|
||||
|
||||
/**
|
||||
* Free windows — drag-out detection. The tab strips already drive HTML5
|
||||
* DnD (payload application/x-dsh-tab) with drops owned by the panes
|
||||
* (split/merge); this shell watches the DOCUMENT (capture) for the same
|
||||
* drag hovering OUTSIDE the panel host: while the pointer is over the
|
||||
* conversation column it arms the drop (preventDefault) and shows a hint
|
||||
* overlay there, and the drop floats the tab at the release point. Targets
|
||||
* inside the host are ignored here, so pane drops keep their behavior
|
||||
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
|
||||
* OS file drags and any DSH drags pass through). Narrow viewports skip
|
||||
* the gesture — the merged drawer covers the conversation, leaving
|
||||
* nothing to drop onto (the tab context menu entry still floats tabs).
|
||||
*/
|
||||
const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
|
||||
const floatHintRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (narrow || sessionId === undefined) return
|
||||
const inPanelHost = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
|
||||
/** The conversation column's rect when the pointer is over it (and not
|
||||
* over our own surfaces); null otherwise. */
|
||||
const overConversation = (event: DragEvent): DOMRect | null => {
|
||||
if (inPanelHost(event.target)) return null
|
||||
const col = centerColRef.current
|
||||
if (col === null || !col.isConnected) return null
|
||||
const rect = col.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return null
|
||||
const { clientX: x, clientY: y } = event
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
|
||||
return rect
|
||||
}
|
||||
const onDragOver = (event: DragEvent): void => {
|
||||
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
|
||||
const rect = overConversation(event)
|
||||
if (rect !== null) {
|
||||
// preventDefault on dragover is what makes the browser deliver the
|
||||
// drop (and drop the "no" cursor) over the conversation area.
|
||||
event.preventDefault()
|
||||
setFloatHint((prev) => {
|
||||
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||
if (prev !== null && prev.left === next.left && prev.top === next.top
|
||||
&& prev.width === next.width && prev.height === next.height) return prev
|
||||
return next
|
||||
})
|
||||
floatHintRef.current = true
|
||||
} else if (floatHintRef.current) {
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
}
|
||||
}
|
||||
const onDrop = (event: DragEvent): void => {
|
||||
if (!floatHintRef.current) return
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
const rect = overConversation(event)
|
||||
if (rect === null) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
|
||||
if (payload === null) return
|
||||
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
|
||||
}
|
||||
const clear = (): void => {
|
||||
if (!floatHintRef.current) return
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
}
|
||||
document.addEventListener('dragover', onDragOver, true)
|
||||
document.addEventListener('drop', onDrop, true)
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
document.removeEventListener('dragover', onDragOver, true)
|
||||
document.removeEventListener('drop', onDrop, true)
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [narrow, sessionId, store])
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
* expands the bottom panel in a session, try to open a fresh terminal tab
|
||||
@@ -1013,6 +1093,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
resizeSplit: (splitId, index, deltaFrac) => {
|
||||
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
|
||||
},
|
||||
// The tab context menu's "move to free window": no drop point exists, so
|
||||
// the window is born over the conversation column's center (the user's
|
||||
// focus area; clamped into the viewport by the reducer) — the same
|
||||
// landing the drag-out gesture produces.
|
||||
floatTab: (tabId) => {
|
||||
const col = centerColRef.current
|
||||
const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
|
||||
const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
|
||||
const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
|
||||
store.reduce(s => floatTab(s, tabId, x, y))
|
||||
},
|
||||
}), [store, sessionId, cwd])
|
||||
|
||||
/**
|
||||
@@ -1385,6 +1476,40 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Free windows: tabs dragged out onto the conversation area (or floated
|
||||
from the tab context menu). They live in the panel host like the
|
||||
panels (viewport coordinates, immune to desktop-shell transforms) but
|
||||
are independent of panel state — a window stays up while panels
|
||||
collapse. The floats array's order is the stacking order; the content
|
||||
reuses the regular tab renderer, so every tab type floats unchanged.
|
||||
*/}
|
||||
{state.floats.map(float => (
|
||||
<FreeWindow
|
||||
key={float.id}
|
||||
float={float}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={tabIconOf}
|
||||
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
|
||||
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
|
||||
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
|
||||
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
|
||||
onClose={() => { ctx.betterSidebar?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
|
||||
/>
|
||||
))}
|
||||
{/*
|
||||
The drag-out hint: while a tab drag hovers the conversation column,
|
||||
a dashed overlay marks the drop zone there (pointer-transparent — it
|
||||
must not disturb the drag it describes).
|
||||
*/}
|
||||
{floatHint !== null && (
|
||||
<div
|
||||
className={css.floatDropHint}
|
||||
style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
|
||||
>
|
||||
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+11
-4
@@ -4,8 +4,9 @@
|
||||
* button cluster, and the + menu that opens new tabs (explorer / git /
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane. Right-clicking a
|
||||
* tab opens the tab context menu (close / close others / close to the left /
|
||||
* close to the right, all scoped to this pane).
|
||||
* tab opens the tab context menu (float as a free window / close / close
|
||||
* others / close to the left / close to the right, the close ones scoped to
|
||||
* this pane).
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -63,6 +64,9 @@ export function TabBar(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||
/** Float a tab out as a free window (the tab context menu's entry; the
|
||||
* drag-to-conversation gesture is handled at the Sidebar shell level). */
|
||||
onFloatTab: (tabId: string) => void
|
||||
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
|
||||
@@ -70,7 +74,7 @@ export function TabBar(props: {
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, getTabIcon, getTabBadge,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
@@ -271,6 +275,7 @@ export function TabBar(props: {
|
||||
open={tabMenu !== null && tabMenuIndex >= 0}
|
||||
onClose={() => { setTabMenu(null) }}
|
||||
items={[
|
||||
{ id: 'float', label: t('moveToFreeWindow') },
|
||||
{ id: 'close', label: t('close') },
|
||||
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
|
||||
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
|
||||
@@ -282,7 +287,9 @@ export function TabBar(props: {
|
||||
setTabMenu(null)
|
||||
const index = tabs.findIndex(tab => tab.id === target.tabId)
|
||||
if (index < 0) return
|
||||
if (id === 'close') {
|
||||
if (id === 'float') {
|
||||
onFloatTab(target.tabId)
|
||||
} else if (id === 'close') {
|
||||
onClose(target.tabId)
|
||||
} else if (id === 'closeOthers') {
|
||||
for (const tab of tabs) {
|
||||
|
||||
@@ -66,6 +66,9 @@ export const ar: Record<string, string> = {
|
||||
closeOtherTabs: 'إغلاق التبويبات الأخرى',
|
||||
closeLeftTabs: 'إغلاق التبويبات على اليسار',
|
||||
closeRightTabs: 'إغلاق التبويبات على اليمين',
|
||||
moveToFreeWindow: 'النقل إلى نافذة حرة',
|
||||
floatDropHint: 'حرّك للفتح في نافذة حرة',
|
||||
dockToSidebar: 'العودة إلى الشريط الجانبي',
|
||||
collapse: 'طي الشريط الجانبي',
|
||||
expand: 'توسيع الشريط الجانبي',
|
||||
collapseBottomPanel: 'طي اللوحة السفلية',
|
||||
|
||||
@@ -51,6 +51,9 @@ export const de: Record<string, string> = {
|
||||
closeOtherTabs: 'Andere Tabs schließen',
|
||||
closeLeftTabs: 'Tabs links schließen',
|
||||
closeRightTabs: 'Tabs rechts schließen',
|
||||
moveToFreeWindow: 'In freies Fenster verschieben',
|
||||
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
|
||||
dockToSidebar: 'Zurück zur Seitenleiste',
|
||||
collapse: 'Seitenleiste einklappen',
|
||||
expand: 'Seitenleiste ausklappen',
|
||||
collapseBottomPanel: 'Unteres Panel einklappen',
|
||||
|
||||
@@ -58,6 +58,9 @@ export const fr: Record<string, string> = {
|
||||
closeOtherTabs: 'Fermer les autres onglets',
|
||||
closeLeftTabs: 'Fermer les onglets à gauche',
|
||||
closeRightTabs: 'Fermer les onglets à droite',
|
||||
moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
|
||||
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
|
||||
dockToSidebar: 'Revenir à la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
expand: 'Déployer la barre latérale',
|
||||
collapseBottomPanel: 'Réduire le panneau inférieur',
|
||||
|
||||
@@ -65,6 +65,9 @@ export const hi: Record<string, string> = {
|
||||
closeOtherTabs: 'अन्य टैब बंद करें',
|
||||
closeLeftTabs: 'बाएँ टैब बंद करें',
|
||||
closeRightTabs: 'दाएँ टैब बंद करें',
|
||||
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
|
||||
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
|
||||
dockToSidebar: 'साइडबार पर वापस जाएँ',
|
||||
collapse: 'साइडबार संक्षिप्त करें',
|
||||
expand: 'साइडबार विस्तृत करें',
|
||||
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
|
||||
|
||||
@@ -63,6 +63,9 @@ export const id: Record<string, string> = {
|
||||
closeOtherTabs: 'Tutup Tab Lain',
|
||||
closeLeftTabs: 'Tutup Tab di Kiri',
|
||||
closeRightTabs: 'Tutup Tab di Kanan',
|
||||
moveToFreeWindow: 'Pindahkan ke jendela bebas',
|
||||
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
|
||||
dockToSidebar: 'Kembali ke bilah samping',
|
||||
collapse: 'Lipat sidebar',
|
||||
expand: 'Bentangkan sidebar',
|
||||
collapseBottomPanel: 'Lipat panel bawah',
|
||||
|
||||
@@ -56,6 +56,9 @@ export const it: Record<string, string> = {
|
||||
closeOtherTabs: 'Chiudi le altre schede',
|
||||
closeLeftTabs: 'Chiudi le schede a sinistra',
|
||||
closeRightTabs: 'Chiudi le schede a destra',
|
||||
moveToFreeWindow: 'Sposta in finestra libera',
|
||||
floatDropHint: 'Rilascia per aprire in una finestra libera',
|
||||
dockToSidebar: 'Torna alla barra laterale',
|
||||
collapse: 'Comprimi barra laterale',
|
||||
expand: 'Espandi barra laterale',
|
||||
collapseBottomPanel: 'Comprimi pannello inferiore',
|
||||
|
||||
@@ -65,6 +65,9 @@ export const ja: Record<string, string> = {
|
||||
closeOtherTabs: '他のタブを閉じる',
|
||||
closeLeftTabs: '左のタブを閉じる',
|
||||
closeRightTabs: '右のタブを閉じる',
|
||||
moveToFreeWindow: 'フリーウィンドウへ移動',
|
||||
floatDropHint: '離すとフリーウィンドウで開きます',
|
||||
dockToSidebar: 'サイドバーへ戻す',
|
||||
collapse: 'サイドバーを折りたたむ',
|
||||
expand: 'サイドバーを展開',
|
||||
collapseBottomPanel: '下パネルを折りたたむ',
|
||||
|
||||
@@ -57,6 +57,9 @@ export const ko: Record<string, string> = {
|
||||
closeOtherTabs: '다른 탭 닫기',
|
||||
closeLeftTabs: '왼쪽 탭 닫기',
|
||||
closeRightTabs: '오른쪽 탭 닫기',
|
||||
moveToFreeWindow: '자유 창으로 이동',
|
||||
floatDropHint: '놓으면 자유 창에서 열립니다',
|
||||
dockToSidebar: '사이드바로 돌아가기',
|
||||
collapse: '사이드바 접기',
|
||||
expand: '사이드바 펼치기',
|
||||
collapseBottomPanel: '하단 패널 접기',
|
||||
|
||||
@@ -63,6 +63,9 @@ export const nl: Record<string, string> = {
|
||||
closeOtherTabs: 'Andere tabbladen sluiten',
|
||||
closeLeftTabs: 'Tabbladen links sluiten',
|
||||
closeRightTabs: 'Tabbladen rechts sluiten',
|
||||
moveToFreeWindow: 'Naar vrij venster verplaatsen',
|
||||
floatDropHint: 'Loslaten om in een vrij venster te openen',
|
||||
dockToSidebar: 'Terug naar de zijbalk',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapseBottomPanel: 'Onderpaneel inklappen',
|
||||
|
||||
@@ -67,6 +67,9 @@ export const pl: Record<string, string> = {
|
||||
closeOtherTabs: 'Zamknij inne karty',
|
||||
closeLeftTabs: 'Zamknij karty po lewej',
|
||||
closeRightTabs: 'Zamknij karty po prawej',
|
||||
moveToFreeWindow: 'Przenieś do wolnego okna',
|
||||
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
|
||||
dockToSidebar: 'Wróć do panelu bocznego',
|
||||
collapse: 'Zwiń panel boczny',
|
||||
expand: 'Rozwiń panel boczny',
|
||||
collapseBottomPanel: 'Zwiń panel dolny',
|
||||
|
||||
@@ -48,6 +48,9 @@ export const pt: Record<string, string> = {
|
||||
closeOtherTabs: 'Fechar outras abas',
|
||||
closeLeftTabs: 'Fechar abas à esquerda',
|
||||
closeRightTabs: 'Fechar abas à direita',
|
||||
moveToFreeWindow: 'Mover para janela flutuante',
|
||||
floatDropHint: 'Solte para abrir em uma janela flutuante',
|
||||
dockToSidebar: 'Voltar para a barra lateral',
|
||||
collapse: 'Recolher barra lateral',
|
||||
expand: 'Expandir barra lateral',
|
||||
collapseBottomPanel: 'Recolher painel inferior',
|
||||
|
||||
@@ -61,6 +61,9 @@ export const ru: Record<string, string> = {
|
||||
closeOtherTabs: 'Закрыть остальные вкладки',
|
||||
closeLeftTabs: 'Закрыть вкладки слева',
|
||||
closeRightTabs: 'Закрыть вкладки справа',
|
||||
moveToFreeWindow: 'Переместить в свободное окно',
|
||||
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
|
||||
dockToSidebar: 'Вернуть на боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapseBottomPanel: 'Свернуть нижнюю панель',
|
||||
|
||||
@@ -48,6 +48,9 @@ export const sv: Record<string, string> = {
|
||||
closeOtherTabs: 'Stäng andra flikar',
|
||||
closeLeftTabs: 'Stäng flikar till vänster',
|
||||
closeRightTabs: 'Stäng flikar till höger',
|
||||
moveToFreeWindow: 'Flytta till fritt fönster',
|
||||
floatDropHint: 'Släpp för att öppna i ett fritt fönster',
|
||||
dockToSidebar: 'Tillbaka till sidopanelen',
|
||||
collapse: 'Komprimera sidopanel',
|
||||
expand: 'Expandera sidopanel',
|
||||
collapseBottomPanel: 'Komprimera bottenpanel',
|
||||
|
||||
@@ -65,6 +65,9 @@ export const th: Record<string, string> = {
|
||||
closeOtherTabs: 'ปิด Tab อื่น',
|
||||
closeLeftTabs: 'ปิด Tab ด้านซ้าย',
|
||||
closeRightTabs: 'ปิด Tab ด้านขวา',
|
||||
moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ',
|
||||
floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ',
|
||||
dockToSidebar: 'กลับไปที่แถบข้าง',
|
||||
collapse: 'ย่อแถบด้านข้าง',
|
||||
expand: 'ขยายแถบด้านข้าง',
|
||||
collapseBottomPanel: 'ย่อพาเนลด้านล่าง',
|
||||
|
||||
@@ -65,6 +65,9 @@ export const tr: Record<string, string> = {
|
||||
closeOtherTabs: 'Diğer sekmeleri kapat',
|
||||
closeLeftTabs: 'Soldaki sekmeleri kapat',
|
||||
closeRightTabs: 'Sağdaki sekmeleri kapat',
|
||||
moveToFreeWindow: 'Serbest pencereye taşı',
|
||||
floatDropHint: 'Serbest pencerede açmak için bırakın',
|
||||
dockToSidebar: 'Kenar çubuğuna geri dön',
|
||||
collapse: 'Kenar çubuğunu daralt',
|
||||
expand: 'Kenar çubuğunu genişlet',
|
||||
collapseBottomPanel: 'Alt paneli daralt',
|
||||
|
||||
@@ -65,6 +65,9 @@ export const vi: Record<string, string> = {
|
||||
closeOtherTabs: 'Đóng các tab khác',
|
||||
closeLeftTabs: 'Đóng tab bên trái',
|
||||
closeRightTabs: 'Đóng tab bên phải',
|
||||
moveToFreeWindow: 'Chuyển sang cửa sổ tự do',
|
||||
floatDropHint: 'Thả để mở trong cửa sổ tự do',
|
||||
dockToSidebar: 'Quay lại thanh bên',
|
||||
collapse: 'Thu gọn thanh bên',
|
||||
expand: 'Mở rộng thanh bên',
|
||||
collapseBottomPanel: 'Thu gọn panel dưới',
|
||||
|
||||
@@ -80,6 +80,9 @@ export const zhHK: Record<string, string> = {
|
||||
closeOtherTabs: '關閉其他標籤',
|
||||
closeLeftTabs: '關閉左側標籤',
|
||||
closeRightTabs: '關閉右側標籤',
|
||||
moveToFreeWindow: '移動到自由視窗',
|
||||
floatDropHint: '放開以在自由視窗中開啟',
|
||||
dockToSidebar: '返回側邊欄',
|
||||
collapse: '收起側邊欄',
|
||||
expand: '展開側邊欄',
|
||||
collapseBottomPanel: '收起底部面板',
|
||||
|
||||
@@ -80,6 +80,9 @@ export const zhMO: Record<string, string> = {
|
||||
closeOtherTabs: '關閉其他標籤',
|
||||
closeLeftTabs: '關閉左側標籤',
|
||||
closeRightTabs: '關閉右側標籤',
|
||||
moveToFreeWindow: '移動到自由視窗',
|
||||
floatDropHint: '放開以在自由視窗中開啟',
|
||||
dockToSidebar: '返回側邊欄',
|
||||
collapse: '收起側邊欄',
|
||||
expand: '展開側邊欄',
|
||||
collapseBottomPanel: '收起底部面板',
|
||||
|
||||
@@ -80,6 +80,9 @@ export const zhTW: Record<string, string> = {
|
||||
closeOtherTabs: '關閉其他標籤',
|
||||
closeLeftTabs: '關閉左側標籤',
|
||||
closeRightTabs: '關閉右側標籤',
|
||||
moveToFreeWindow: '移動到自由視窗',
|
||||
floatDropHint: '放開以在自由視窗中開啟',
|
||||
dockToSidebar: '返回側邊欄',
|
||||
collapse: '收起側邊欄',
|
||||
expand: '展開側邊欄',
|
||||
collapseBottomPanel: '收起底部面板',
|
||||
|
||||
@@ -72,6 +72,9 @@ export const zh = {
|
||||
closeOtherTabs: '关闭其他页签',
|
||||
closeLeftTabs: '关闭左侧页签',
|
||||
closeRightTabs: '关闭右侧页签',
|
||||
moveToFreeWindow: '移动到自由窗口',
|
||||
floatDropHint: '松开以在自由窗口中打开',
|
||||
dockToSidebar: '回到侧边栏',
|
||||
collapse: '折叠侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
collapseBottomPanel: '折叠底部面板',
|
||||
@@ -402,6 +405,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
closeOtherTabs: 'Close Other Tabs',
|
||||
closeLeftTabs: 'Close Tabs to the Left',
|
||||
closeRightTabs: 'Close Tabs to the Right',
|
||||
moveToFreeWindow: 'Move to Free Window',
|
||||
floatDropHint: 'Release to open in a free window',
|
||||
dockToSidebar: 'Dock Back to Sidebar',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
collapseBottomPanel: 'Collapse bottom panel',
|
||||
|
||||
+36
-3
@@ -22,8 +22,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
|
||||
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
|
||||
leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.15.2'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.16.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
@@ -485,6 +485,9 @@ export const SIDEBAR_SERVICE_VERSION = '0.15.2'
|
||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
|
||||
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
|
||||
* - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
|
||||
* id focus targets RAISE the floating window (never duplicate the tab or
|
||||
* expand panels), closeTab on a floating tab closes it with its window.
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
@@ -497,6 +500,7 @@ export const SIDEBAR_FEATURES = [
|
||||
'pluginSettings',
|
||||
'urlTarget',
|
||||
'settingSelect',
|
||||
'floatWindows',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
@@ -651,6 +655,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
.concat(state.floats.map(f => f.tab))
|
||||
const existedByKey = key !== undefined
|
||||
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
const existedById = tabOpenIn(state, tab.id)
|
||||
@@ -675,12 +680,24 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
} else {
|
||||
// A focus happened: resolve the tab that is actually active now and
|
||||
// report THAT to onActivate (never the caller's un-inserted seed).
|
||||
// The pool covers free windows too — a floating instance focuses by
|
||||
// raising, and the callback must see the real (patched) tab.
|
||||
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
.concat(landed.floats.map(f => f.tab))
|
||||
activated = key !== undefined
|
||||
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
: candidates.find(candidate => candidate.id === tab.id)
|
||||
activated ??= tab
|
||||
}
|
||||
// A CONTENT open that focuses an existing FLOATING tab is already in
|
||||
// sight (free windows render regardless of panel state): expanding a
|
||||
// panel for it would point the user at a pane the content is not in.
|
||||
if (
|
||||
!isCreation
|
||||
&& floatWithTab(landed, activated?.id ?? tab.id) !== undefined
|
||||
) {
|
||||
return landed
|
||||
}
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
// viewports the two workbenches merge into one drawer, so the drawer
|
||||
@@ -728,6 +745,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// Unknown tab ids are a strict no-op: no state churn, no notify, no
|
||||
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
|
||||
if (!tabOpenIn(state, tabId)) return state
|
||||
// A floating tab closes WITH its window — the float is the tab's pane.
|
||||
const float = floatWithTab(state, tabId)
|
||||
if (float !== undefined) {
|
||||
closed = float.tab
|
||||
return closeFloatByTab(state, tabId)
|
||||
}
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
@@ -764,6 +787,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op (no state churn / notify).
|
||||
if (!tabOpenIn(state, tabId)) return state
|
||||
// A floating tab "activates" by raising its window — no pane switch.
|
||||
const float = floatWithTab(state, tabId)
|
||||
if (float !== undefined) {
|
||||
activated = float.tab
|
||||
return raiseFloat(state, float.id)
|
||||
}
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
@@ -825,6 +854,10 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
|
||||
}
|
||||
// A floating instance focuses by raising its window (no duplicate tab,
|
||||
// no panel expansion — the window is the tab's pane).
|
||||
const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
|
||||
if (floated !== undefined) return raiseFloat(state, floated.id)
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
|
||||
@@ -282,6 +282,141 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ── Free windows ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* One floating tab: the same card surface as the panels, at viewport
|
||||
coordinates inside the panel host (immune to shell transforms). z-index
|
||||
42: above the panels (40) so a window drags across them, below the
|
||||
toggle cluster (45) and the app's overlay stack (100+, issue #52).
|
||||
Stacking among windows is the floats array order (siblings share the
|
||||
z-index; DOM order wins). Deliberately NO box transition — the header/
|
||||
resize drags write left/top/width/height per frame. Elevated surface,
|
||||
so unlike the flat panels it earns a shadow token. */
|
||||
.floatWindow {
|
||||
position: absolute;
|
||||
z-index: 42;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Re-enable interaction inside the click-transparent panel host. */
|
||||
pointer-events: auto;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
overflow: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.floatWindowDragging {
|
||||
will-change: left, top, width, height;
|
||||
}
|
||||
|
||||
/* The header: the move/dock-back drag handle, mirroring the tab strip's
|
||||
chrome (34px, hairline bottom, secondary label). */
|
||||
.floatHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 4px 0 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.floatWindowDragging .floatHeader {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.floatTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mirrors .tabClose. */
|
||||
.floatClose {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.floatClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.floatContent {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The SE resize corner (mirror of .cornerHandle). */
|
||||
.floatResize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
z-index: 2;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.floatResize:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
/* A pane highlighted as the dock target while a float header drag hovers it
|
||||
(the attribute is written directly by the drag at pointer cadence;
|
||||
skin-addressable via [data-dsh-pane][data-dsh-float-dock-over]). */
|
||||
.pane[data-dsh-float-dock-over] {
|
||||
outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* The drag-out hint over the conversation column: a dashed frame marking
|
||||
the drop zone, pointer-transparent so it never disturbs the drag it
|
||||
describes. z-index 46: above the windows (42) for unambiguous feedback,
|
||||
still below the app's overlay stack. */
|
||||
.floatDropHint {
|
||||
position: absolute;
|
||||
z-index: 46;
|
||||
pointer-events: none;
|
||||
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 12%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.floatDropHintLabel {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
|
||||
|
||||
/* Frameless desktop shells make the top strip of the web content draggable
|
||||
@@ -293,7 +428,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
browsers and in shells without drag regions — safe everywhere. */
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tabBar {
|
||||
.tabBar,
|
||||
.floatHeader {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkbenchActions {
|
||||
/** 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
|
||||
/** Float a docked tab out as a free window (tab context menu entry). */
|
||||
floatTab: (tabId: string) => void
|
||||
}
|
||||
|
||||
/** One divider: pointer-capture drag translating px deltas into fractions.
|
||||
@@ -167,6 +169,7 @@ function LeafView(props: {
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||
data-dsh-pane={leaf.id}
|
||||
onPointerDown={() => { actions.focusPane(leaf.id) }}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -207,6 +210,7 @@ function LeafView(props: {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
}}
|
||||
onFloatTab={actions.floatTab}
|
||||
/>
|
||||
{leaf.tabs.length > 0 ? (
|
||||
/*
|
||||
|
||||
+272
-54
@@ -58,6 +58,23 @@ export interface SidebarSplit {
|
||||
|
||||
export type SplitNode = SidebarLeaf | SidebarSplit
|
||||
|
||||
/**
|
||||
* One free window: a tab dragged out of the workbench onto the conversation
|
||||
* area floats in the panel host at viewport coordinates. The tab is OWNED by
|
||||
* the window exactly like a pane owns its tabs (moved, not copied); geometry
|
||||
* persists with the session so a reload restores the window in place.
|
||||
* Stacking order is the array order (last = topmost).
|
||||
*/
|
||||
export interface FloatWindow {
|
||||
id: string
|
||||
tab: SidebarTab
|
||||
/** Viewport coordinates of the window's top-left corner. */
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/** The full per-session state. */
|
||||
export interface SidebarState {
|
||||
panelOpen: boolean
|
||||
@@ -87,6 +104,8 @@ export interface SidebarState {
|
||||
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
|
||||
* tabs never cross panels — the two panels only share panel-size drags). */
|
||||
bottomSplits: SplitNode
|
||||
/** Free windows (tabs dragged out onto the conversation area). */
|
||||
floats: FloatWindow[]
|
||||
}
|
||||
|
||||
export const PANEL_MIN = 280
|
||||
@@ -94,9 +113,16 @@ export const PANEL_MAX = 640
|
||||
export const PANEL_DEFAULT = 400
|
||||
export const TAB_MAX_WIDTH = 160
|
||||
/** Bottom panel geometry contract (mirrors the width contract; the upper
|
||||
* bound is the viewport, enforced by {@link setBottomHeight}). */
|
||||
* bound is the viewport, enforced by {@link setBottomHeight}). */
|
||||
export const BOTTOM_MIN = 120
|
||||
export const BOTTOM_DEFAULT = 220
|
||||
/** Free-window geometry contract: the floor keeps the window usable (a
|
||||
* header plus some content), the ceiling is the viewport. */
|
||||
export const FLOAT_MIN_W = 320
|
||||
export const FLOAT_MIN_H = 200
|
||||
/** Geometry a fresh free window starts with (clamped to the viewport). */
|
||||
export const FLOAT_DEFAULT_W = 480
|
||||
export const FLOAT_DEFAULT_H = 360
|
||||
|
||||
let nextIdCounter = 0
|
||||
/** Unique pane/tab id within one state instance. */
|
||||
@@ -143,6 +169,14 @@ function maxCounterId(parsed: unknown): number {
|
||||
}
|
||||
walk((parsed as Record<string, unknown> | null)?.splits)
|
||||
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
|
||||
// Free windows mint through the same shared counter; their ids must seed it
|
||||
// too (a persisted float:N with a lower pane max would collide otherwise).
|
||||
const floats = (parsed as Record<string, unknown> | null)?.floats
|
||||
if (Array.isArray(floats)) {
|
||||
for (const float of floats) {
|
||||
if (float !== null && typeof float === 'object') consider((float as Record<string, unknown>).id)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
@@ -181,6 +215,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed:
|
||||
bottomHeight: BOTTOM_DEFAULT,
|
||||
bottomOpenedOnce: false,
|
||||
bottomSplits: bottomLeaf,
|
||||
floats: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,10 +315,22 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
|
||||
return node.children.flatMap(allLeaves)
|
||||
}
|
||||
|
||||
/** Whether a tab exists anywhere in a state (either tree, any pane). */
|
||||
/** Whether a tab exists anywhere in a state (either tree, any pane, or any
|
||||
* free window — a floating tab is as open as a docked one). */
|
||||
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
|
||||
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
|| state.floats.some(float => float.tab.id === tabId)
|
||||
}
|
||||
|
||||
/** The free window holding a tab id, if any. */
|
||||
export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
|
||||
return state.floats.find(float => float.tab.id === tabId)
|
||||
}
|
||||
|
||||
/** The free window with the given window id, if any. */
|
||||
export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
|
||||
return state.floats.find(float => float.id === floatId)
|
||||
}
|
||||
|
||||
/** Replace a leaf with a split of it plus a fresh empty leaf. */
|
||||
@@ -453,25 +500,26 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
/** Update the display fields of one open tab (title / path / meta) without
|
||||
* re-opening it. The browser tab persists its current URL and hostname
|
||||
* title through this reducer so a reload restores the visited page. A
|
||||
* missing tab id is a no-op. The tab may live in either tree. */
|
||||
* missing tab id is a no-op. The tab may live in either tree or a free
|
||||
* window. */
|
||||
export function patchTab(
|
||||
state: SidebarState,
|
||||
tabId: string,
|
||||
patch: { title?: string; path?: string; meta?: unknown },
|
||||
): SidebarState {
|
||||
let changed = false
|
||||
const apply = (tab: SidebarTab): SidebarTab => {
|
||||
changed = true
|
||||
return {
|
||||
...tab,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}
|
||||
}
|
||||
const walk = (node: SplitNode): SplitNode => {
|
||||
if (node.kind === 'leaf') {
|
||||
const tabs = node.tabs.map(tab => {
|
||||
if (tab.id !== tabId) return tab
|
||||
changed = true
|
||||
return {
|
||||
...tab,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}
|
||||
})
|
||||
const tabs = node.tabs.map(tab => (tab.id === tabId ? apply(tab) : tab))
|
||||
return tabs === node.tabs ? node : { ...node, tabs }
|
||||
}
|
||||
const children = node.children.map(walk)
|
||||
@@ -479,7 +527,8 @@ export function patchTab(
|
||||
}
|
||||
const splits = walk(state.splits)
|
||||
const bottomSplits = walk(state.bottomSplits)
|
||||
return changed ? { ...state, splits, bottomSplits } : state
|
||||
const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
|
||||
return changed ? { ...state, splits, bottomSplits, floats } : state
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -504,11 +553,14 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
const targetKey = treeOf(state, targetId)
|
||||
// Id-based safety net: if a tab with the same id exists, focus it.
|
||||
// Id-based safety net: if a tab with the same id exists, focus it — in a
|
||||
// pane (activate) or in a free window (raise, no panel switch).
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
const floated = floatWithTab(state, tab.id)
|
||||
if (floated !== undefined) return raiseFloat(state, floated.id)
|
||||
return {
|
||||
...state,
|
||||
activePane: targetId,
|
||||
@@ -674,6 +726,137 @@ export function resizeSplitIn(state: SidebarState, splitId: string, index: numbe
|
||||
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
|
||||
}
|
||||
|
||||
// ── Free windows ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The viewport size, or Infinity where there is no (usable) window — unit
|
||||
* tests stub partial window objects, and a NaN bound would poison geometry. */
|
||||
function viewportW(): number {
|
||||
return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
|
||||
}
|
||||
|
||||
function viewportH(): number {
|
||||
return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
|
||||
}
|
||||
|
||||
/** Clamp free-window geometry: sizes respect the floor and the viewport, and
|
||||
* the position keeps the whole window inside the viewport. Without a window
|
||||
* (unit tests) only the floor applies — the caller's values pass through. */
|
||||
export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick<FloatWindow, 'x' | 'y' | 'w' | 'h'> {
|
||||
const vw = viewportW()
|
||||
const vh = viewportH()
|
||||
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
|
||||
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
|
||||
return {
|
||||
x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
|
||||
y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
|
||||
w: width,
|
||||
h: height,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Float a docked tab: remove it from its pane (either tree; an emptied pane
|
||||
* collapses like any move) and append a free window centered on the drop
|
||||
* point, with the default size clamped to the viewport. The stacking order
|
||||
* is the array order, so a fresh window is born topmost. An unknown tab id
|
||||
* (or one already floating) is a strict no-op.
|
||||
*/
|
||||
export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
|
||||
let source: SidebarLeaf | undefined
|
||||
let key: 'splits' | 'bottomSplits' | undefined
|
||||
for (const treeKey of ['splits', 'bottomSplits'] as const) {
|
||||
source = leafWithTab(state[treeKey], tabId)
|
||||
if (source !== undefined) { key = treeKey; break }
|
||||
}
|
||||
if (key === undefined || source === undefined) return state
|
||||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||||
let emptied = false
|
||||
let node = mapLeaf(state[key], 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) node = removeLeafAt(node, source.id)
|
||||
const window = clampFloatGeometry(
|
||||
x - FLOAT_DEFAULT_W / 2,
|
||||
y - FLOAT_DEFAULT_H / 2,
|
||||
FLOAT_DEFAULT_W,
|
||||
FLOAT_DEFAULT_H,
|
||||
)
|
||||
const next: SidebarState = {
|
||||
...state,
|
||||
[key]: node,
|
||||
floats: [...state.floats, { id: uid('float'), tab, ...window }],
|
||||
}
|
||||
// The pane the user was working in may have just collapsed with the tab.
|
||||
if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
|
||||
return next
|
||||
}
|
||||
|
||||
/** Move a free window (clamped to the viewport); unknown ids are a no-op. */
|
||||
export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
|
||||
const float = floatById(state, floatId)
|
||||
if (float === undefined) return state
|
||||
const geo = clampFloatGeometry(x, y, float.w, float.h)
|
||||
if (geo.x === float.x && geo.y === float.y) return state
|
||||
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
|
||||
}
|
||||
|
||||
/** Resize a free window from its SE corner: the top-left corner stays
|
||||
* anchored, sizes clamp to the floor and to the viewport's remaining room. */
|
||||
export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
|
||||
const float = floatById(state, floatId)
|
||||
if (float === undefined) return state
|
||||
const vw = viewportW()
|
||||
const vh = viewportH()
|
||||
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
|
||||
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
|
||||
if (width === float.w && height === float.h) return state
|
||||
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
|
||||
}
|
||||
|
||||
/** Bring a free window to the top (the array's end). Already topmost (or the
|
||||
* only window) returns the same reference — no persist churn on every click. */
|
||||
export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
|
||||
if (state.floats.length < 2) return state
|
||||
const index = state.floats.findIndex(f => f.id === floatId)
|
||||
if (index < 0 || index === state.floats.length - 1) return state
|
||||
const floats = [...state.floats]
|
||||
const [raised] = floats.splice(index, 1)
|
||||
floats.push(raised!)
|
||||
return { ...state, floats }
|
||||
}
|
||||
|
||||
/** Dock a free window back into a pane (center merge): the tab joins the
|
||||
* target pane and activates. `toPane` defaults to the active pane with the
|
||||
* right tree's first leaf as the stale-id fallback (mirrors
|
||||
* {@link openTabInActivePane}). Unknown window ids are a no-op. */
|
||||
export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
|
||||
const float = floatById(state, floatId)
|
||||
if (float === undefined) return state
|
||||
let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
|
||||
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
const targetKey = treeOf(state, targetId)
|
||||
return {
|
||||
...state,
|
||||
floats: state.floats.filter(f => f.id !== floatId),
|
||||
activePane: targetId,
|
||||
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, float.tab]
|
||||
leaf.active = float.tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the free window holding a tab (the tab closes WITH the window —
|
||||
* the caller fires the descriptor's onClose lifecycle). */
|
||||
export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
|
||||
if (!state.floats.some(f => f.tab.id === tabId)) return state
|
||||
return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
|
||||
}
|
||||
|
||||
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
|
||||
export const AGENT_TAB_PREFIX = 'agent:'
|
||||
|
||||
@@ -709,6 +892,7 @@ export function reconcileAgentTerminals(
|
||||
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
|
||||
): SidebarState {
|
||||
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
.concat(state.floats.map(float => float.tab))
|
||||
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
|
||||
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
|
||||
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
|
||||
@@ -716,17 +900,22 @@ export function reconcileAgentTerminals(
|
||||
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
|
||||
if (toAdd.length === 0 && toRemove.length === 0) return state
|
||||
// Remove tabs whose uuids vanished from the server list (the agent closed
|
||||
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
|
||||
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
|
||||
// a FLOATED agent terminal leaves with its window.
|
||||
let splits = state.splits
|
||||
let floats = state.floats
|
||||
for (const tab of toRemove) {
|
||||
const leaf = leafWithTab(splits, tab.id)
|
||||
if (leaf !== undefined) {
|
||||
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
|
||||
}
|
||||
if (floats.some(float => float.tab.id === tab.id)) {
|
||||
floats = floats.filter(float => float.tab.id !== tab.id)
|
||||
}
|
||||
}
|
||||
// Add tabs for new uuids (the agent created a terminal). They land in the
|
||||
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
|
||||
let next: SidebarState = { ...state, splits }
|
||||
let next: SidebarState = { ...state, splits, floats }
|
||||
for (const terminal of toAdd) {
|
||||
const tab: SidebarTab = {
|
||||
id: agentTabId(terminal.uuid),
|
||||
@@ -843,6 +1032,31 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
|
||||
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
|
||||
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
|
||||
// Free windows arrived with the float feature: a missing field on an older
|
||||
// persisted state defaults to none (like the bottom-panel fields above).
|
||||
// Malformed ENTRIES drop individually — unlike the split trees, where
|
||||
// corruption resets the whole state, a bad window must not cost the user
|
||||
// their whole layout. Geometry clamps into the CURRENT viewport so a stale
|
||||
// off-screen window stays reachable; the window ids join the same
|
||||
// uniqueness set as the pane/split ids (the uid counter mints for both).
|
||||
const floats: FloatWindow[] = []
|
||||
if (Array.isArray(record.floats)) {
|
||||
for (const entry of record.floats) {
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
const candidate = entry as Record<string, unknown>
|
||||
if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
|
||||
const tab = sanitizePersistedTab(candidate.tab)
|
||||
if (tab === undefined || tab === 'diff') continue
|
||||
if (
|
||||
typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
|
||||
|| typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
|
||||
|| typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
|
||||
|| typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
|
||||
) continue
|
||||
seen.add(candidate.id)
|
||||
floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
|
||||
}
|
||||
}
|
||||
const requestedActivePane = typeof record.activePane === 'string'
|
||||
? (reid.get(record.activePane) ?? record.activePane)
|
||||
: null
|
||||
@@ -869,6 +1083,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
// auto-terminal exactly once after the upgrade.
|
||||
bottomOpenedOnce: record.bottomOpenedOnce === true,
|
||||
bottomSplits,
|
||||
floats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +1117,42 @@ function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>):
|
||||
return fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one persisted tab record. @returns the clean tab, `'diff'` for an
|
||||
* ephemeral diff tab (dropped everywhere — diff tabs never survive a reload),
|
||||
* or undefined when the record is malformed (structural corruption when it
|
||||
* comes from a split-tree leaf; a malformed FLOAT tab only drops the window).
|
||||
*/
|
||||
function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
|
||||
if (tab === null || typeof tab !== 'object') return undefined
|
||||
const candidate = tab as Record<string, unknown>
|
||||
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
|
||||
if (candidate.type === 'diff') return 'diff'
|
||||
// Tab types are an open set (external plugins register their own); accept
|
||||
// any string type here — an unregistered type renders an <OrphanedTab/> at
|
||||
// view time and recovers if its plugin loads later.
|
||||
if (typeof candidate.type !== 'string') return undefined
|
||||
// The standalone explorer tab type merged INTO the editor (the single
|
||||
// files window): a persisted explorer tab reopens as an editor home tab —
|
||||
// no path, tree panel open (an existing meta object survives).
|
||||
if (candidate.type === 'explorer') {
|
||||
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
|
||||
? candidate.meta as Record<string, unknown>
|
||||
: undefined
|
||||
return { id: candidate.id, type: 'editor', title: 'Files', meta: { treeOpen: true, ...meta } }
|
||||
}
|
||||
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the persisted
|
||||
// value already went through JSON.parse, so it is inherently serializable —
|
||||
// carry it through verbatim (absent on older states).
|
||||
return {
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
title: candidate.title,
|
||||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||||
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
|
||||
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined
|
||||
@@ -911,46 +1162,13 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
const tabs: SidebarTab[] = []
|
||||
let droppedDiff = false
|
||||
for (const tab of record.tabs) {
|
||||
if (tab === null || typeof tab !== 'object') return undefined
|
||||
const candidate = tab as Record<string, unknown>
|
||||
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
|
||||
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
|
||||
// they never survive a reload, so a stale ref (e.g. a wrong staged
|
||||
// side persisted by an older build) cannot resurface as a dead tab
|
||||
// showing "no text changes" after every refresh.
|
||||
if (candidate.type === 'diff') {
|
||||
const clean = sanitizePersistedTab(tab)
|
||||
if (clean === undefined) return undefined
|
||||
if (clean === 'diff') {
|
||||
droppedDiff = true
|
||||
continue
|
||||
}
|
||||
// Tab types are an open set (external plugins register their own);
|
||||
// accept any string type here — an unregistered type renders an
|
||||
// <OrphanedTab/> at view time and recovers if its plugin loads later.
|
||||
if (typeof candidate.type !== 'string') return undefined
|
||||
// The standalone explorer tab type merged INTO the editor (the single
|
||||
// files window): a persisted explorer tab reopens as an editor home
|
||||
// tab — no path, tree panel open (an existing meta object survives).
|
||||
if (candidate.type === 'explorer') {
|
||||
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
|
||||
? candidate.meta as Record<string, unknown>
|
||||
: undefined
|
||||
tabs.push({
|
||||
id: candidate.id,
|
||||
type: 'editor',
|
||||
title: 'Files',
|
||||
meta: { treeOpen: true, ...meta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
|
||||
// persisted value already went through JSON.parse, so it is inherently
|
||||
// serializable — carry it through verbatim (absent on older states).
|
||||
tabs.push({
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
title: candidate.title,
|
||||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||||
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
|
||||
})
|
||||
tabs.push(clean)
|
||||
}
|
||||
const active = typeof record.active === 'string' ? record.active : null
|
||||
// An active pointer into a dropped diff tab is expected after the drop;
|
||||
|
||||
Reference in New Issue
Block a user