Merge origin/main into feat/wait-banner: resolve floats retirement + openTabInBottomPane
This commit is contained in:
@@ -42,7 +42,7 @@ import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
|
||||
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
@@ -178,8 +178,7 @@ export function EditorHost(props: {
|
||||
*/
|
||||
const openFileSide = (absolute: string): void => {
|
||||
store.reduce((state) => {
|
||||
const key = treeOf(state, tab.id)
|
||||
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
|
||||
const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
|
||||
const fresh: SidebarTab = {
|
||||
id: mintTabId(),
|
||||
type: 'editor',
|
||||
@@ -187,8 +186,8 @@ export function EditorHost(props: {
|
||||
path: absolute,
|
||||
meta: { treeOpen: false },
|
||||
}
|
||||
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
|
||||
return { ...state, [key]: node, activePane: leafId }
|
||||
const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false)
|
||||
return { ...state, bottomSplits: node, activePane: leafId }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
{/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the
|
||||
column-flex chain (window → .floatContent → .paneTab) is what
|
||||
gives the component a definite width via align-stretch — the exact
|
||||
sizing contract a tab gets inside a pane. */}
|
||||
<div className={css.floatContent}>
|
||||
<div className={css.paneTab}>{renderTab(float.tab, true, float.id)}</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
|
||||
* swap keeps the page alive.
|
||||
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
|
||||
* viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the
|
||||
* viewer/editor shows a strip inside ITS OWN pane; the workbench shell, the
|
||||
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
|
||||
* must never take down the whole sidebar).
|
||||
*
|
||||
|
||||
@@ -54,11 +54,8 @@ import clsx from 'clsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
@@ -572,7 +569,6 @@ export function SettingsBody(props: {
|
||||
*/
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
@@ -624,9 +620,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
setPrefs(parsePrefs(view.value))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
@@ -657,9 +651,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
setPrefs(outcome.ok ? outcome.prefs : previous)
|
||||
}
|
||||
|
||||
/** Optimistically apply one pref patch, then commit (revert on failure). */
|
||||
@@ -672,10 +664,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
applyPref({ openByDefault: next })
|
||||
}
|
||||
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
|
||||
@@ -778,20 +766,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the icon
|
||||
@@ -867,51 +841,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenTitle')}
|
||||
checked={prefs.openByDefault}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenPathTitle')}
|
||||
checked={prefs.interceptOpenPath}
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
type SidechatLiveEvent,
|
||||
type SidechatThreadInfo,
|
||||
} from '../sidechat-core.ts'
|
||||
import {
|
||||
@@ -112,6 +113,8 @@ const inFlightStarts = new Set<string>()
|
||||
* the afterSeq delta and never re-download what they already hold). */
|
||||
interface ThreadCache {
|
||||
entries: SidebarHistoryEntry[]
|
||||
/** The CURRENT attempt's live deltas (replaced every pull, never merged). */
|
||||
live: SidechatLiveEvent[]
|
||||
}
|
||||
|
||||
/** Row-render labels (locale-dependent, memoized once per mount). */
|
||||
@@ -383,7 +386,7 @@ export function SideChatView(props: {
|
||||
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const cacheRef = useRef<ThreadCache>({ entries: [] })
|
||||
const cacheRef = useRef<ThreadCache>({ entries: [], live: [] })
|
||||
// The previous poll's rows (see the mapping's reuse pass below).
|
||||
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
@@ -457,14 +460,18 @@ export function SideChatView(props: {
|
||||
try {
|
||||
const cache = cacheRef.current
|
||||
const afterSeq = cache.entries.at(-1)?.event.seq
|
||||
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
|
||||
const { events, live } = await api.sidechatEvents(childId, afterSeq, controller.signal)
|
||||
// The live set is the CURRENT attempt, so it replaces whatever the
|
||||
// previous pull held; a settled step drops out of the host buffer and
|
||||
// its durable assistant/message takes over in the mapping.
|
||||
cache.live = live ?? []
|
||||
if (events.length > 0) {
|
||||
// Wire events arrive as parsed JSON; the mirror narrows data to the
|
||||
// record the mapping reads.
|
||||
const incoming = events.map(event => ({ event: event as SidebarSessionEvent }))
|
||||
cache.entries = mergeBySeq(cache.entries, incoming)
|
||||
setRevision(value => value + 1)
|
||||
}
|
||||
setRevision(value => value + 1)
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last rows.
|
||||
}
|
||||
@@ -482,7 +489,7 @@ export function SideChatView(props: {
|
||||
// Reset the transcript cache whenever the binding changes, then focus
|
||||
// the composer — it owns the first message of a fresh thread.
|
||||
useEffect(() => {
|
||||
cacheRef.current = { entries: [] }
|
||||
cacheRef.current = { entries: [], live: [] }
|
||||
prevRowsRef.current = []
|
||||
controllerRef.current?.abort()
|
||||
setError(null)
|
||||
@@ -532,8 +539,24 @@ export function SideChatView(props: {
|
||||
// The previous poll's rows ride into the mapping so unchanged rows keep
|
||||
// their object identity (see reuseRows): the 2s poll re-renders only the
|
||||
// changed tail instead of re-parsing markdown for the whole transcript.
|
||||
// The live deltas are appended AFTER the durable events (they are the
|
||||
// in-flight tail), and a delta whose step already settled durably is
|
||||
// dropped: the durable message is authoritative and would otherwise render
|
||||
// twice.
|
||||
const rows = useMemo(() => {
|
||||
const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
|
||||
const cache = cacheRef.current
|
||||
const settled = new Set(
|
||||
cache.entries
|
||||
.filter(entry => entry.event.type === 'assistant/message')
|
||||
.map(entry => {
|
||||
const data = entry.event.data as { turn?: unknown; step?: unknown }
|
||||
return `${String(data.turn)}:${String(data.step)}`
|
||||
}),
|
||||
)
|
||||
const live = cache.live
|
||||
.filter(event => !settled.has(`${String(event.data.turn)}:${String(event.data.step)}`))
|
||||
.map(event => ({ event: event as SidebarSessionEvent }))
|
||||
const next = threadId === undefined ? [] : transcriptRows([...cache.entries, ...live], prevRowsRef.current)
|
||||
prevRowsRef.current = next
|
||||
return next
|
||||
},
|
||||
|
||||
+192
-554
File diff suppressed because it is too large
Load Diff
+6
-14
@@ -4,9 +4,8 @@
|
||||
* 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 (float as a free window / close / close
|
||||
* others / close to the left / close to the right, the close ones scoped to
|
||||
* this pane).
|
||||
* tab opens the tab context menu (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'
|
||||
@@ -68,9 +67,6 @@ 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
|
||||
/**
|
||||
* Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or
|
||||
* `'global'` to pin (the shell snapshots the home cwd), or `null` to
|
||||
@@ -85,7 +81,7 @@ export function TabBar(props: {
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, onPinTab, getTabIcon, getTabBadge,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onPinTab, getTabIcon, getTabBadge,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
@@ -298,9 +294,8 @@ export function TabBar(props: {
|
||||
// The target tab drives the pin entry's shape: terminal tabs
|
||||
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
|
||||
// row (pinned). Non-terminal tabs and missing onPinTab get no
|
||||
// pin entry at all — the menu stays exactly the legacy 5-item
|
||||
// shape. Pinned VIRTUAL tabs (injected from other sessions)
|
||||
// get a stripped menu: only Unpin + Close (no float, no
|
||||
// pin entry at all. Pinned VIRTUAL tabs (injected from other
|
||||
// sessions) get a stripped menu: only Unpin + Close (no
|
||||
// close-others/left/right — those are pane-scoped operations
|
||||
// that don't apply to cross-session virtual tabs).
|
||||
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
|
||||
@@ -325,7 +320,6 @@ export function TabBar(props: {
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'float', label: t('moveToFreeWindow') },
|
||||
...pinEntries,
|
||||
{ id: 'close', label: t('close') },
|
||||
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
|
||||
@@ -339,9 +333,7 @@ export function TabBar(props: {
|
||||
setTabMenu(null)
|
||||
const index = tabs.findIndex(tab => tab.id === target.tabId)
|
||||
if (index < 0) return
|
||||
if (id === 'float') {
|
||||
onFloatTab(target.tabId)
|
||||
} else if (id === 'pinWorkspace') {
|
||||
if (id === 'pinWorkspace') {
|
||||
onPinTab?.(target.tabId, 'workspace')
|
||||
} else if (id === 'pinGlobal') {
|
||||
onPinTab?.(target.tabId, 'global')
|
||||
|
||||
+6
-3
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type { SidebarSessionEvent } from '../context-types.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
@@ -394,9 +394,12 @@ export const api = {
|
||||
call<SidechatThreadInfo>('sidechat.info', { childId }),
|
||||
/** One transcript pull of a Side Chat thread: the thread's OWN events
|
||||
* (the inherited seed is cut host-side and never crosses the wire).
|
||||
* `afterSeq` narrows the response to the delta beyond it (poll tail). */
|
||||
* `afterSeq` narrows the response to the delta beyond it (poll tail).
|
||||
* `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them
|
||||
* outside the session log) — the CURRENT attempt on every pull, never a
|
||||
* delta, so the caller replaces its live set instead of appending. */
|
||||
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
|
||||
call<{ events: SidechatLogEvent[] }>('sidechat.events', {
|
||||
call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', {
|
||||
childId,
|
||||
...(afterSeq !== undefined ? { afterSeq } : {}),
|
||||
}, signal),
|
||||
|
||||
@@ -22,7 +22,7 @@ import { SubagentView } from '../SubagentView.tsx'
|
||||
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
|
||||
import { api } from '../api.ts'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
@@ -72,7 +72,7 @@ function terminalUuid(): string {
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
return allLeaves(state.bottomSplits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
description: () => t('guideDescFiles'),
|
||||
// The single files window: an editor tab with no path IS the file
|
||||
// explorer (empty hint + docked tree); with a path it previews/edits
|
||||
// the file. Visible in the + menu in the explorer's old slot.
|
||||
@@ -148,6 +149,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
// log, and the git status needs a fetch — both stay out of the badge).
|
||||
id: 'git',
|
||||
title: () => t('changes'),
|
||||
description: () => t('guideDescGit'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
@@ -155,31 +157,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
const count = opCountOf(scope.sessionId)
|
||||
return count === undefined || count === 0 ? null : count
|
||||
},
|
||||
// Declarative settings: the diff-open picker (free window vs docked
|
||||
// pane) renders as an iconed select row under the changes card's gear
|
||||
// in the Side card settings page.
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'changesDiffFloat',
|
||||
type: 'select',
|
||||
title: () => t('changesDiffOpenTitle'),
|
||||
desc: () => t('changesDiffOpenDesc'),
|
||||
options: [
|
||||
{
|
||||
value: true,
|
||||
icon: (size: number) => <IconFloatWindowOutline16 size={size} />,
|
||||
title: () => t('changesDiffOpenFloat'),
|
||||
desc: () => t('changesDiffOpenFloatDesc'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: (size: number) => <IconPanelBottomOutline16 size={size} />,
|
||||
title: () => t('changesDiffOpenPane'),
|
||||
desc: () => t('changesDiffOpenPaneDesc'),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => (
|
||||
<ChangesTab
|
||||
ctx={ctx}
|
||||
@@ -195,6 +172,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
description: () => t('guideDescSubagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
@@ -223,6 +201,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'sidechat',
|
||||
title: () => t('sideChat'),
|
||||
description: () => t('guideDescSidechat'),
|
||||
icon: (size: number) => <IconNewChatOutline16 size={size} />,
|
||||
order: 35,
|
||||
// Codex-style: EVERY side conversation is its own tab. A plain open
|
||||
@@ -268,6 +247,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
description: () => t('guideDescTerminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
@@ -332,6 +312,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'browser',
|
||||
title: () => t('browser'),
|
||||
description: () => t('guideDescBrowser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch, the link-takeover
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/** Stable selector for the DSH AppFrame conversation slot. */
|
||||
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
|
||||
/**
|
||||
* Anchor selectors for the DSH AppFrame conversation slot host, newest first:
|
||||
*
|
||||
* - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped
|
||||
* keyed `main` slot, so the conversation renders under `main.conversation`
|
||||
* (ConversationPanel -> `renderSlot('main.conversation')`);
|
||||
* - alpha.1 and earlier spell the same host `conversation`.
|
||||
*
|
||||
* Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2
|
||||
* boundary; only one exists in any given host.
|
||||
*/
|
||||
const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]'
|
||||
|
||||
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
|
||||
export const CENTER_COLUMN_REVALIDATE_MS = 1500
|
||||
@@ -27,6 +37,22 @@ const validatedAt = new WeakMap<HTMLElement, number>()
|
||||
*/
|
||||
const documentStyleState = new WeakMap<Document, string | null>()
|
||||
|
||||
/**
|
||||
* From the matched conversation slot host, walk up through transparent slot
|
||||
* hosts to the real column: DSH slot hosts render with `display: contents`,
|
||||
* so on alpha.2 the `main.conversation` host (and any further keyed slot
|
||||
* wrappers above it) contributes no box, and the center column is the first
|
||||
* ancestor whose computed display is NOT `contents`.
|
||||
*/
|
||||
function columnOfSlotHost(host: Element): HTMLElement | null {
|
||||
const view = host.ownerDocument.defaultView
|
||||
let node = host.parentElement
|
||||
while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') {
|
||||
node = node.parentElement
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AppFrame center column while keeping streaming mutations cheap.
|
||||
*
|
||||
@@ -74,7 +100,8 @@ export function resolveCenterColumn(
|
||||
}
|
||||
|
||||
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
|
||||
const col = query()?.parentElement as HTMLElement | null | undefined
|
||||
const host = query()
|
||||
const col = host === null || host === undefined ? null : columnOfSlotHost(host)
|
||||
if (col === null || col === undefined || !col.isConnected) return undefined
|
||||
|
||||
validatedAt.set(col, now)
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* (repository truth: staged/unstaged files, commit box, history) and the
|
||||
* session round (agent truth: every file the model read, wrote, or edited).
|
||||
* Both lenses preview their selections in a shared resizable bottom pane
|
||||
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
|
||||
* docked in a pane, or floated as a free window per the tab's setting.
|
||||
* The active lens and the pane height persist in the tab's meta, so the tab
|
||||
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
|
||||
* in the workbench's diff pane. The active lens and the pane height persist
|
||||
* in the tab's meta, so the tab
|
||||
* reopens exactly where it was left.
|
||||
*
|
||||
* The session events ride the host's `changes.ops` route (the client
|
||||
@@ -19,7 +19,7 @@ import type { TabComponentProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
||||
import type { SidebarDiffRef } from '../state.ts'
|
||||
import { GitLens } from './GitLens.tsx'
|
||||
import { SessionLens } from './SessionLens.tsx'
|
||||
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
|
||||
@@ -137,18 +137,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
|
||||
}
|
||||
|
||||
/** Expand the current git preview into the dedicated diff tab: docked
|
||||
* into the shell's diff pane, or floated as a free window centered on
|
||||
* the viewport when the tab's diff-open setting asks for it (default). */
|
||||
/** Expand the current git preview into the dedicated diff tab, docked
|
||||
* into the workbench's diff pane. */
|
||||
const expandPreview = (): void => {
|
||||
if (preview?.kind !== 'git') return
|
||||
const diffTab = diffTabOf(preview.ref)
|
||||
onOpenDiff?.(diffTab)
|
||||
if (store.getPrefs().changesDiffFloat !== false) {
|
||||
const x = Math.round(window.innerWidth / 2)
|
||||
const y = Math.round(window.innerHeight / 2)
|
||||
store.reduce(state => floatTab(state, diffTab.id, x, y))
|
||||
}
|
||||
onOpenDiff?.(diffTabOf(preview.ref))
|
||||
}
|
||||
|
||||
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
|
||||
|
||||
+2
-26
@@ -1,24 +1,12 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
|
||||
* the top-right cluster. Per-tab icons live on the tab descriptors
|
||||
* icon library has none), a diff glyph, and the bottom-workbench toggle.
|
||||
* Per-tab icons live on the tab descriptors
|
||||
* (`descriptor.icon`), not in a type-keyed switch — the icon mapping was
|
||||
* registry-ized with the tab types.
|
||||
*/
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
|
||||
* along its RIGHT edge, in the app's outline style (1.5px stroke,
|
||||
* currentColor).
|
||||
*/
|
||||
export const IconPanelRightOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
|
||||
* along its BOTTOM edge, in the app's outline style.
|
||||
@@ -161,15 +149,3 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Free-window glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
* a background frame with a detached rounded mini-window floating over its
|
||||
* top-right — the changes tab's "diff opens as a free window" setting.
|
||||
*/
|
||||
export const IconFloatWindowOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="4.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="9.5" y="1.5" width="5" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
+27
-15
@@ -17,7 +17,11 @@ import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-load
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { createNativeTabRecords } from './native/tab-adapter.tsx'
|
||||
import { registerNativeSurface } from './native/index.ts'
|
||||
import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
|
||||
import { createNativeSurface } from './native/surface.ts'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
||||
@@ -137,6 +141,27 @@ export function apply(ctx: Context): void {
|
||||
// are ready by the time the sidebar renders.
|
||||
const service = createBetterSidebarService(sidebarStore)
|
||||
ctx.provide('betterSidebar', service)
|
||||
// The native right-Sidebar surface: the plugin's content is registered as
|
||||
// DSH tab types (one per descriptor) and every open routes there, so the
|
||||
// right column belongs to the host and only the bottom workbench stays
|
||||
// plugin-owned. Both halves live for this fiber's lifetime.
|
||||
const nativeRecords = createNativeTabRecords()
|
||||
const nativeSurface = createNativeSurface(ctx, nativeRecords)
|
||||
service.setSurface(nativeSurface)
|
||||
ctx.effect(
|
||||
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
|
||||
'dsh-better-sidebar: native right-Sidebar registrations',
|
||||
)
|
||||
// The bottom workbench's expand/collapse button in DSH's session header
|
||||
// (the header's corner seat belongs to the native sidebar's own control).
|
||||
ctx.effect(
|
||||
() => registerBottomToggle(ctx, sidebarStore),
|
||||
'dsh-better-sidebar: bottom-workbench toggle',
|
||||
)
|
||||
ctx.effect(
|
||||
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
|
||||
'dsh-better-sidebar: native right-Sidebar surface',
|
||||
)
|
||||
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
|
||||
// instead of "Terminal 1". Start with a safe fallback and replace it as
|
||||
// soon as the host shell info resolves. Tabs created before the response
|
||||
@@ -148,8 +173,7 @@ export function apply(ctx: Context): void {
|
||||
terminalTitle = name
|
||||
const snapshot = service.getSnapshot()
|
||||
if (snapshot.state === undefined) return
|
||||
const tabs = allLeaves(snapshot.state.splits)
|
||||
.concat(allLeaves(snapshot.state.bottomSplits))
|
||||
const tabs = allLeaves(snapshot.state.bottomSplits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
for (const tab of tabs) {
|
||||
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
|
||||
@@ -342,18 +366,6 @@ export function apply(ctx: Context): void {
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerOpenPathInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: open-path interception',
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
|
||||
import { revealPaths, type SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import { wrapOpenWorkspacePath, type OpenWorkspacePathService } from './openpath-intercept.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
||||
@@ -25,18 +24,10 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* The produced files the turn-tail selector last matched for the visible
|
||||
* session. The "Show in folder" gesture carries no file path of its own
|
||||
* (`'.'`), so the reveal highlights exactly these rows when available.
|
||||
*/
|
||||
let lastProduced: readonly string[] = []
|
||||
|
||||
/**
|
||||
* Reveal the produced files in the sidebar explorer: expand their parent
|
||||
* directories, highlight the rows, and focus the explorer tab (expanding the
|
||||
* hosting panel when it is collapsed). Unknown files fall back to revealing
|
||||
* the workspace root itself.
|
||||
* directories, highlight the rows, and focus the explorer tab. Unknown
|
||||
* files fall back to revealing the workspace root itself.
|
||||
*/
|
||||
export function revealInExplorer(
|
||||
ctx: Context,
|
||||
@@ -53,15 +44,6 @@ export function revealInExplorer(
|
||||
? files.map(path => resolveSidebarPath(cwd, path))
|
||||
: cwd === undefined ? [] : [cwd]
|
||||
store.reduce(state => revealPaths(state, cwd, targets))
|
||||
// A type-only open never auto-expands the panel (only content opens do,
|
||||
// see service.openTab) — so a reveal opens the panel itself when it is
|
||||
// collapsed, exactly like the subagent auto-open flows, or the highlight
|
||||
// would be set on an invisible panel.
|
||||
store.reduce(s => (s.panelOpen ? s : togglePanel(s)))
|
||||
// Pin the landing to the right panel: the files window must appear where
|
||||
// the panel just expanded, not in a bottom-panel pane the user last
|
||||
// touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
// Focus the single-instance editor home tab (the files window) where the
|
||||
// reveal highlight renders. Read via ctx.get like every other internal
|
||||
// consumer (#357): the provider is not on this fiber chain, so a direct
|
||||
@@ -137,9 +119,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
select: (owner) => {
|
||||
if (store.getSuspended()) return null
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
const matched = selectProducedFiles(owner)
|
||||
if (matched !== null) lastProduced = matched
|
||||
return matched
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
@@ -149,38 +129,3 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
}),
|
||||
}, SidebarProducedFiles))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the chat file-open interception: shadows
|
||||
* `remote.session.openWorkspacePath` — the single funnel every chat-side
|
||||
* file open goes through on alpha hosts (tool-row path links, the
|
||||
* produced-files row, prose mentions, inline-code paths) — so opens land in
|
||||
* the sidebar editor instead of the Host OS. The folder-reveal gesture
|
||||
* ("Show in folder" passes `'.'`) is the one exception: it is routed to the
|
||||
* explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's
|
||||
* enable switch; declined opens fall through to the original remote call.
|
||||
*
|
||||
* The `remote.session` namespace service mounts asynchronously (the gateway
|
||||
* client creates it when the session-controller contribution arrives) and
|
||||
* is recreated on contribution remounts, so the wrapper installs through
|
||||
* `ctx.inject`: the callback runs once the service exists and re-runs after
|
||||
* every remount, re-applying the shadow on the fresh instance. Returns the
|
||||
* disposer (disposes the inject fiber, which restores the original method
|
||||
* descriptor — HMR-safe).
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
const fiber = ctx.inject(['remote.session'], (fctx) => {
|
||||
fctx.effect(() => {
|
||||
const service = fctx.get('remote.session') as OpenWorkspacePathService
|
||||
return wrapOpenWorkspacePath(service, {
|
||||
takeoverEnabled: () => !store.getSuspended()
|
||||
&& store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
|
||||
})
|
||||
}, 'dsh-better-sidebar: open-path interception wrap')
|
||||
})
|
||||
return () => { void fiber.dispose() }
|
||||
}
|
||||
|
||||
+16
-25
@@ -1,37 +1,28 @@
|
||||
/**
|
||||
* Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`.
|
||||
* The conversation column (output + composer) must keep at least
|
||||
* {@link PANEL_MIN} of the viewport after the bottom panel claims height.
|
||||
* Size written to `--dsh-sidebar-height`. The conversation column (output +
|
||||
* composer) must keep at least {@link CONVERSATION_MIN} of the viewport after
|
||||
* the bottom workbench claims height; a closed workbench pushes 0. The right
|
||||
* column belongs to DSH's native Sidebar, so this shell pushes no width.
|
||||
*/
|
||||
import { PANEL_MIN } from './state.ts'
|
||||
import { CONVERSATION_MIN } from './state.ts'
|
||||
|
||||
export interface LayoutPushInput {
|
||||
narrow: boolean
|
||||
panelOpen: boolean
|
||||
bottomOpen: boolean
|
||||
width: number
|
||||
bottomHeight: number
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
export interface LayoutPushSize {
|
||||
width: number
|
||||
export interface BottomPushInput {
|
||||
/** Whether the bottom workbench is expanded. */
|
||||
open: boolean
|
||||
/** Committed (or mid-drag) bottom height in px. */
|
||||
height: number
|
||||
/** Viewport height the cap is measured against (visual viewport when known). */
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
function finiteNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
/** Compute the live layout-push size. Narrow drawers float and push 0. */
|
||||
export function layoutPushSize(input: LayoutPushInput): LayoutPushSize {
|
||||
if (input.narrow) return { width: 0, height: 0 }
|
||||
const viewportWidth = finiteNonNegative(input.viewportWidth)
|
||||
/** Compute the live layout-push height for the bottom workbench. */
|
||||
export function bottomPushHeight(input: BottomPushInput): number {
|
||||
if (!input.open) return 0
|
||||
const viewportHeight = finiteNonNegative(input.viewportHeight)
|
||||
const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight))
|
||||
return {
|
||||
width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0,
|
||||
height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0,
|
||||
}
|
||||
const maxHeight = Math.max(0, viewportHeight - Math.min(CONVERSATION_MIN, viewportHeight))
|
||||
return Math.min(finiteNonNegative(input.height), maxHeight)
|
||||
}
|
||||
|
||||
+17
-49
@@ -1,47 +1,25 @@
|
||||
/**
|
||||
* Layout push: an open panel occupies layout instead of floating over it.
|
||||
* The right panel reserves padding inside the AppFrame three-column grid,
|
||||
* so the app sidebar, conversation, and details column all remain to its
|
||||
* left while the frame's border-box stays viewport-wide. Harness measures
|
||||
* that border-box to choose desktop versus narrow presentation; preserving
|
||||
* it prevents a desktop window from entering narrow mode merely because the
|
||||
* plugin panel opened.
|
||||
* Layout push: the bottom workbench occupies layout instead of floating over
|
||||
* the conversation. The right column belongs to DSH's native Sidebar, so this
|
||||
* stylesheet pushes no width — only the workbench's height.
|
||||
*
|
||||
* AppFrame positions its details drag handle from the measured border-box.
|
||||
* Move that handle left by the same reserved width so it stays on the details
|
||||
* column edge. The ordinary sidebar handle remains at its native coordinate.
|
||||
*
|
||||
* The bottom panel squeezes only the conversation column. The anchor is the
|
||||
* The workbench squeezes only the conversation column. The anchor is the
|
||||
* [data-dsh-center-col] tag the Sidebar shell's locator writes on the
|
||||
* frame's measured center grid item (Sidebar.tsx locate — not a positional
|
||||
* path: the frame also contains an overlay layer and drag handles). A
|
||||
* stretched grid item shrinks by its bottom margin, so the conversation
|
||||
* output and composer lift without touching either sidebar.
|
||||
*
|
||||
* Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed).
|
||||
* Expand/collapse uses the host theme duration; drags disable transitions so
|
||||
* every reserved edge tracks the pointer.
|
||||
* The size rides a CSS variable updated by the Sidebar shell (0 while
|
||||
* collapsed). Expand/collapse uses the host theme duration; drags disable
|
||||
* transitions so the reserved edge tracks the pointer.
|
||||
*/
|
||||
/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same
|
||||
AppFrame only as the root slot's direct child. */
|
||||
#root [data-dsh-frame],
|
||||
#root > [data-slot="root"] > div {
|
||||
box-sizing: border-box;
|
||||
padding-right: var(--dsh-sidebar-width, 0px);
|
||||
transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
#root [data-dsh-frame] > [data-side="details"],
|
||||
#root > [data-slot="root"] > div > [data-side="details"] {
|
||||
transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px)));
|
||||
transition:
|
||||
left var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
|
||||
locate/measureCenter) tags the measured column node with
|
||||
[data-dsh-center-col] — one attribute write per node lifetime. The rule
|
||||
locate/measureCenter, resolving via center-column.ts) tags the measured
|
||||
column node with [data-dsh-center-col] — one attribute write per node
|
||||
lifetime. The locator anchors on the conversation slot host
|
||||
(`main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on alpha.1)
|
||||
and skips the transparent display:contents slot hosts above it. The rule
|
||||
used to anchor on a structural has()-selector over the conversation slot
|
||||
wrapper, whose match cache invalidates on every #root subtree mutation
|
||||
(the streaming chat mutates it constantly), re-evaluating the selector
|
||||
@@ -73,13 +51,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
padding-right: 78px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame],
|
||||
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div,
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"],
|
||||
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"],
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
#root [data-dsh-frame][data-dragging] > [data-side="details"],
|
||||
#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -103,10 +75,6 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root [data-dsh-frame],
|
||||
#root > [data-slot="root"] > div,
|
||||
#root [data-dsh-frame] > [data-side="details"],
|
||||
#root > [data-slot="root"] > div > [data-side="details"],
|
||||
#root [data-dsh-center-col] {
|
||||
transition: none;
|
||||
}
|
||||
@@ -117,10 +85,10 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
* The ui-primitives Menu clamps its main portal list into the viewport but
|
||||
* positions nested submenus with static CSS only — anchored at the parent
|
||||
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
|
||||
* Docked on the window's right edge, both defaults are the worst case for
|
||||
* this panel: a tall "open with" submenu escapes past the viewport top when
|
||||
* the row menu opens near the top, and past the right edge on narrow panels
|
||||
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
|
||||
* Both defaults are the worst case for our menus: a tall "open with" submenu
|
||||
* escapes past the viewport top when the row menu opens near the top, and
|
||||
* past the right edge in a narrow workbench pane (a pane can be far narrower
|
||||
* than the card 218 + gap + submenu 165 a submenu needs).
|
||||
*
|
||||
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
|
||||
* words "down" and/or "left" — for exactly the lifetime of one of OUR
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ar: Record<string, string> = {
|
||||
files: 'الملفات',
|
||||
guideDescFiles: 'استعرض شجرة ملفات مساحة العمل؛ انقر للمعاينة أو التحرير',
|
||||
guideDescGit: 'تغييرات Git وتعديلات ملفات هذه الجلسة في تبويب واحد',
|
||||
guideDescSubagent: 'بنية الوكلاء الفرعيين والمهام الخلفية مباشرةً',
|
||||
guideDescSidechat: 'خيط متابعة مستقل يرث سياق هذه الجلسة',
|
||||
guideDescTerminal: 'صدفة حقيقية (xterm + pty) مع إعادة الاتصال',
|
||||
guideDescBrowser: 'متصفح مدمج يمكنه فتح روابط المحادثة',
|
||||
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
|
||||
changesRead: 'قراءة',
|
||||
changesMdReading: 'قراءة',
|
||||
@@ -82,7 +88,6 @@ export const ar: Record<string, string> = {
|
||||
closeOtherTabs: 'إغلاق التبويبات الأخرى',
|
||||
closeLeftTabs: 'إغلاق التبويبات على اليسار',
|
||||
closeRightTabs: 'إغلاق التبويبات على اليمين',
|
||||
moveToFreeWindow: 'النقل إلى نافذة حرة',
|
||||
floatDropHint: 'حرّك للفتح في نافذة حرة',
|
||||
dockToSidebar: 'العودة إلى الشريط الجانبي',
|
||||
pinTerminal: 'تثبيت الطرفية',
|
||||
@@ -97,8 +102,6 @@ export const ar: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'مثبتة عام',
|
||||
pinnedRailLabel: 'الطرفيات المثبتة',
|
||||
closePinnedTerminal: 'إغلاق الطرفية',
|
||||
collapse: 'طي الشريط الجانبي',
|
||||
expand: 'توسيع الشريط الجانبي',
|
||||
collapseBottomPanel: 'طي اللوحة السفلية',
|
||||
expandBottomPanel: 'توسيع اللوحة السفلية',
|
||||
terminalError: 'فشل اتصال الطرفية',
|
||||
@@ -156,12 +159,6 @@ export const ar: Record<string, string> = {
|
||||
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
|
||||
changesClosePreview: 'إغلاق المعاينة',
|
||||
changesResizePreview: 'تغيير حجم المعاينة',
|
||||
changesDiffOpenTitle: 'يفتح الفرق كـ',
|
||||
changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
|
||||
changesDiffOpenFloat: 'نافذة عائمة',
|
||||
changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
|
||||
changesDiffOpenPane: 'لوحة',
|
||||
changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
|
||||
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
|
||||
staged: 'مُدرج',
|
||||
unstaged: 'غير مُدرج',
|
||||
@@ -235,13 +232,6 @@ export const ar: Record<string, string> = {
|
||||
settingsIntro: 'إدارة ما تعرضه البطاقة الجانبية وكيف تتصرف',
|
||||
settingsPopupDesc: 'تكوين خيارات ذات صلة بـ {feature}',
|
||||
settingsDone: 'تم',
|
||||
settingsOpenTitle: 'فتح افتراضياً للمحادثات الجديدة',
|
||||
settingsOpenDesc: 'توسيع البطاقة الجانبية تلقائياً للمحادثات الجديدة تماماً؛ المحادثات الموجودة تحتفظ بتخطيطاتها',
|
||||
settingsWidthTitle: 'الحصة الافتراضية للعرض',
|
||||
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
|
||||
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
|
||||
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
|
||||
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
|
||||
settingsTitleBarTitle: 'وضع توافق الموضع',
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
*/
|
||||
export const de: Record<string, string> = {
|
||||
files: 'Dateien',
|
||||
guideDescFiles: 'Dateibaum des Arbeitsbereichs durchsuchen; Klick öffnet Vorschau oder Editor',
|
||||
guideDescGit: 'Git-Änderungen und Dateiänderungen dieser Sitzung in einem Tab',
|
||||
guideDescSubagent: 'Subagent-Topologie und Hintergrundaufgaben, live',
|
||||
guideDescSidechat: 'Ein unabhängiger Folge-Thread mit dem Kontext dieser Sitzung',
|
||||
guideDescTerminal: 'Eine echte Shell (xterm + pty) mit Wiederverbindung',
|
||||
guideDescBrowser: 'Eingebetteter Browser, der Chat-Links übernehmen kann',
|
||||
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
|
||||
changesRead: 'Lesen',
|
||||
changesMdReading: 'Lesen',
|
||||
@@ -67,7 +73,6 @@ 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',
|
||||
pinTerminal: 'Terminal anheften',
|
||||
@@ -82,8 +87,6 @@ export const de: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Global angeheftet',
|
||||
pinnedRailLabel: 'Angeheftete Terminals',
|
||||
closePinnedTerminal: 'Terminal schließen',
|
||||
collapse: 'Seitenleiste einklappen',
|
||||
expand: 'Seitenleiste ausklappen',
|
||||
collapseBottomPanel: 'Unteres Panel einklappen',
|
||||
expandBottomPanel: 'Unteres Panel ausklappen',
|
||||
terminalError: 'Terminalverbindung fehlgeschlagen',
|
||||
@@ -141,12 +144,6 @@ export const de: Record<string, string> = {
|
||||
changesOpenDiffTab: 'In eigenem Tab öffnen',
|
||||
changesClosePreview: 'Vorschau schließen',
|
||||
changesResizePreview: 'Vorschauhöhe anpassen',
|
||||
changesDiffOpenTitle: 'Diff öffnet als',
|
||||
changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert',
|
||||
changesDiffOpenFloat: 'Freies Fenster',
|
||||
changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten',
|
||||
changesDiffOpenPane: 'Bereich',
|
||||
changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)',
|
||||
changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar',
|
||||
staged: 'Gestaged',
|
||||
unstaged: 'Nicht gestaged',
|
||||
@@ -220,13 +217,6 @@ export const de: Record<string, string> = {
|
||||
settingsIntro: 'Verwalten Sie, was die Seitenkarte anzeigt und wie sie sich verhält',
|
||||
settingsPopupDesc: 'Optionen für „{feature}“ konfigurieren',
|
||||
settingsDone: 'Fertig',
|
||||
settingsOpenTitle: 'Für neue Unterhaltungen standardmäßig geöffnet',
|
||||
settingsOpenDesc: 'Die Seitenkarte wird für brandneue Unterhaltungen automatisch ausgeklappt; bestehende Unterhaltungen behalten ihr eigenes Layout',
|
||||
settingsWidthTitle: 'Standardbreite',
|
||||
settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
|
||||
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
|
||||
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
|
||||
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
|
||||
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
/** fr dictionary for the `betterSidebar` namespace. */
|
||||
export const fr: Record<string, string> = {
|
||||
files: 'Fichiers',
|
||||
guideDescFiles: 'Parcourir l\'arborescence du workspace ; un clic ouvre l\'aperçu ou l\'édition',
|
||||
guideDescGit: 'Modifications Git et éditions de cette session dans un seul onglet',
|
||||
guideDescSubagent: 'Topologie des sous-agents et tâches d\'arrière-plan, en direct',
|
||||
guideDescSidechat: 'Un fil de suivi indépendant héritant du contexte de cette session',
|
||||
guideDescTerminal: 'Un vrai shell (xterm + pty) avec reconnexion',
|
||||
guideDescBrowser: 'Navigateur intégré, qui peut reprendre les liens du chat',
|
||||
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
|
||||
changesRead: 'Lecture',
|
||||
changesMdReading: 'Lecture',
|
||||
@@ -74,7 +80,6 @@ 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',
|
||||
pinTerminal: 'Épingler le terminal',
|
||||
@@ -89,8 +94,6 @@ export const fr: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Épinglé globalement',
|
||||
pinnedRailLabel: 'Terminaux épinglés',
|
||||
closePinnedTerminal: 'Fermer le terminal',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
expand: 'Déployer la barre latérale',
|
||||
collapseBottomPanel: 'Réduire le panneau inférieur',
|
||||
expandBottomPanel: 'Déployer le panneau inférieur',
|
||||
terminalError: 'Échec de la connexion au terminal',
|
||||
@@ -148,12 +151,6 @@ export const fr: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
|
||||
changesClosePreview: 'Fermer l\'aperçu',
|
||||
changesResizePreview: 'Redimensionner l\'aperçu',
|
||||
changesDiffOpenTitle: 'Le diff s\'ouvre en',
|
||||
changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff',
|
||||
changesDiffOpenFloat: 'Fenêtre flottante',
|
||||
changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan',
|
||||
changesDiffOpenPane: 'Panneau',
|
||||
changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)',
|
||||
changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles',
|
||||
staged: 'En scène',
|
||||
unstaged: 'Hors scène',
|
||||
@@ -227,13 +224,6 @@ export const fr: Record<string, string> = {
|
||||
settingsIntro: 'Gérer le contenu affiché par la carte latérale et son comportement par défaut',
|
||||
settingsPopupDesc: 'Configurer les options liées à « {feature} »',
|
||||
settingsDone: 'Terminé',
|
||||
settingsOpenTitle: 'Ouvert par défaut pour les nouvelles sessions',
|
||||
settingsOpenDesc: 'Déployer automatiquement la carte latérale lors de la création d’une session ; les sessions existantes conservent chacune leur disposition',
|
||||
settingsWidthTitle: 'Part de largeur par défaut',
|
||||
settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
|
||||
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
|
||||
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
|
||||
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
|
||||
settingsTitleBarTitle: 'Mode de compatibilité de position',
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const hi: Record<string, string> = {
|
||||
files: 'फ़ाइलें',
|
||||
guideDescFiles: 'वर्कस्पेस फ़ाइल ट्री देखें; क्लिक करने पर प्रीव्यू या संपादन खुलता है',
|
||||
guideDescGit: 'Git बदलाव और इस सत्र के फ़ाइल बदलाव एक ही टैब में',
|
||||
guideDescSubagent: 'सबएजेंट टोपोलॉजी और बैकग्राउंड कार्य, लाइव',
|
||||
guideDescSidechat: 'इस सत्र का संदर्भ विरासत में लेने वाला स्वतंत्र फ़ॉलो-अप थ्रेड',
|
||||
guideDescTerminal: 'असली शेल (xterm + pty), रीकनेक्ट सहित',
|
||||
guideDescBrowser: 'अंतर्निहित ब्राउज़र, चैट के लिंक भी खोल सकता है',
|
||||
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
|
||||
changesRead: 'पढ़ना',
|
||||
changesMdReading: 'पठन',
|
||||
@@ -81,7 +87,6 @@ export const hi: Record<string, string> = {
|
||||
closeOtherTabs: 'अन्य टैब बंद करें',
|
||||
closeLeftTabs: 'बाएँ टैब बंद करें',
|
||||
closeRightTabs: 'दाएँ टैब बंद करें',
|
||||
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
|
||||
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
|
||||
dockToSidebar: 'साइडबार पर वापस जाएँ',
|
||||
pinTerminal: 'टर्मिनल पिन करें',
|
||||
@@ -96,8 +101,6 @@ export const hi: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'वैश्विक रूप से पिन किया गया',
|
||||
pinnedRailLabel: 'पिन किए गए टर्मिनल',
|
||||
closePinnedTerminal: 'टर्मिनल बंद करें',
|
||||
collapse: 'साइडबार संक्षिप्त करें',
|
||||
expand: 'साइडबार विस्तृत करें',
|
||||
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
|
||||
expandBottomPanel: 'निचला पैनल विस्तृत करें',
|
||||
terminalError: 'टर्मिनल कनेक्शन विफल',
|
||||
@@ -155,12 +158,6 @@ export const hi: Record<string, string> = {
|
||||
changesOpenDiffTab: 'अलग diff टैब में खोलें',
|
||||
changesClosePreview: 'पूर्वावलोकन बंद करें',
|
||||
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
|
||||
changesDiffOpenTitle: 'diff खुलता है',
|
||||
changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
|
||||
changesDiffOpenFloat: 'मुक्त विंडो',
|
||||
changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
|
||||
changesDiffOpenPane: 'पैनल',
|
||||
changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
|
||||
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
|
||||
staged: 'स्टेज किया',
|
||||
unstaged: 'अनस्टेज',
|
||||
@@ -234,13 +231,6 @@ export const hi: Record<string, string> = {
|
||||
settingsIntro: 'साइड कार्ड क्या दिखाता है और कैसे व्यवहार करता है प्रबंधित करें',
|
||||
settingsPopupDesc: '{feature} के लिए संबंधित विकल्प कॉन्फ़िगर करें',
|
||||
settingsDone: 'पूर्ण',
|
||||
settingsOpenTitle: 'नए वार्तालाप के लिए डिफ़ॉल्ट रूप से खोलें',
|
||||
settingsOpenDesc: 'बिल्कुल नए वार्तालाप के लिए साइड कार्ड स्वतः विस्तृत करें; मौजूदा वार्तालाप अपना लेआउट रखते हैं',
|
||||
settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
|
||||
settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
|
||||
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
|
||||
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
|
||||
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
|
||||
settingsTitleBarTitle: 'स्थिति संगतता मोड',
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
|
||||
export const id: Record<string, string> = {
|
||||
files: 'Berkas',
|
||||
guideDescFiles: 'Telusuri pohon berkas workspace; klik untuk pratinjau atau edit',
|
||||
guideDescGit: 'Perubahan Git dan perubahan berkas sesi ini dalam satu tab',
|
||||
guideDescSubagent: 'Topologi subagent dan tugas latar belakang, langsung',
|
||||
guideDescSidechat: 'Utas lanjutan independen yang mewarisi konteks sesi ini',
|
||||
guideDescTerminal: 'Shell sungguhan (xterm + pty), bisa menyambung ulang',
|
||||
guideDescBrowser: 'Peramban tertanam, bisa mengambil alih tautan chat',
|
||||
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
|
||||
changesRead: 'Baca',
|
||||
changesMdReading: 'Baca',
|
||||
@@ -79,7 +85,6 @@ 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',
|
||||
pinTerminal: 'Sematkan Terminal',
|
||||
@@ -94,8 +99,6 @@ export const id: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Disematkan global',
|
||||
pinnedRailLabel: 'Terminal Tersemat',
|
||||
closePinnedTerminal: 'Tutup Terminal',
|
||||
collapse: 'Lipat sidebar',
|
||||
expand: 'Bentangkan sidebar',
|
||||
collapseBottomPanel: 'Lipat panel bawah',
|
||||
expandBottomPanel: 'Bentangkan panel bawah',
|
||||
terminalError: 'Koneksi terminal gagal',
|
||||
@@ -153,12 +156,6 @@ export const id: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Buka di tab diff terpisah',
|
||||
changesClosePreview: 'Tutup pratinjau',
|
||||
changesResizePreview: 'Ubah ukuran pratinjau',
|
||||
changesDiffOpenTitle: 'Diff terbuka sebagai',
|
||||
changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff',
|
||||
changesDiffOpenFloat: 'Jendela bebas',
|
||||
changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas',
|
||||
changesDiffOpenPane: 'Panel',
|
||||
changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)',
|
||||
changesLoadError: 'Catatan berkas sesi sedang tidak tersedia',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
@@ -232,13 +229,6 @@ export const id: Record<string, string> = {
|
||||
settingsIntro: 'Kelola apa yang ditampilkan kartu samping dan perilakunya',
|
||||
settingsPopupDesc: 'Konfigurasikan opsi terkait untuk {feature}',
|
||||
settingsDone: 'Selesai',
|
||||
settingsOpenTitle: 'Buka secara default untuk obrolan baru',
|
||||
settingsOpenDesc: 'Bentangkan kartu samping secara otomatis untuk obrolan baru; obrolan yang sudah ada mempertahankan tata letaknya',
|
||||
settingsWidthTitle: 'Porsi lebar default',
|
||||
settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
|
||||
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
|
||||
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
|
||||
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
|
||||
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
export const it: Record<string, string> = {
|
||||
files: 'File',
|
||||
guideDescFiles: 'Sfoglia l\'albero del workspace; un clic apre anteprima o editor',
|
||||
guideDescGit: 'Modifiche Git e modifiche ai file di questa sessione in un solo tab',
|
||||
guideDescSubagent: 'Topologia dei subagent e attività in background, in tempo reale',
|
||||
guideDescSidechat: 'Un thread di approfondimento indipendente che eredita il contesto della sessione',
|
||||
guideDescTerminal: 'Una vera shell (xterm + pty) con riconnessione',
|
||||
guideDescBrowser: 'Browser integrato, può aprire i link della chat',
|
||||
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
|
||||
changesRead: 'Lettura',
|
||||
changesMdReading: 'Lettura',
|
||||
@@ -72,7 +78,6 @@ 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',
|
||||
pinTerminal: 'Blocca Terminale',
|
||||
@@ -87,8 +92,6 @@ export const it: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Bloccato globalmente',
|
||||
pinnedRailLabel: 'Terminali Bloccati',
|
||||
closePinnedTerminal: 'Chiudi Terminale',
|
||||
collapse: 'Comprimi barra laterale',
|
||||
expand: 'Espandi barra laterale',
|
||||
collapseBottomPanel: 'Comprimi pannello inferiore',
|
||||
expandBottomPanel: 'Espandi pannello inferiore',
|
||||
terminalError: 'Connessione del terminale non riuscita',
|
||||
@@ -146,12 +149,6 @@ export const it: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Apri in una scheda diff',
|
||||
changesClosePreview: 'Ridimensiona l\'anteprima',
|
||||
changesResizePreview: 'Ridimensiona anteprima',
|
||||
changesDiffOpenTitle: 'Il diff si apre come',
|
||||
changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff',
|
||||
changesDiffOpenFloat: 'Finestra libera',
|
||||
changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano',
|
||||
changesDiffOpenPane: 'Pannello',
|
||||
changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)',
|
||||
changesLoadError: 'I record dei file della sessione non sono disponibili',
|
||||
staged: 'In stage',
|
||||
unstaged: 'Non in stage',
|
||||
@@ -225,13 +222,6 @@ export const it: Record<string, string> = {
|
||||
settingsIntro: 'Gestisca cosa mostra la scheda laterale e come si comporta',
|
||||
settingsPopupDesc: 'Configuri le opzioni relative a {feature}',
|
||||
settingsDone: 'Fatto',
|
||||
settingsOpenTitle: 'Apri per impostazione predefinita per le nuove conversazioni',
|
||||
settingsOpenDesc: 'Espande automaticamente la scheda laterale per le conversazioni nuove; le conversazioni esistenti mantengono il proprio layout',
|
||||
settingsWidthTitle: 'Quota di larghezza predefinita',
|
||||
settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
|
||||
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
|
||||
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
|
||||
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
|
||||
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ja: Record<string, string> = {
|
||||
files: 'ファイル',
|
||||
guideDescFiles: 'ワークスペースのファイルツリーを閲覧し、クリックでプレビュー/編集',
|
||||
guideDescGit: 'Git の変更と今回のセッションのファイル変更を 1 つのタブに',
|
||||
guideDescSubagent: 'サブエージェントの構成とバックグラウンドタスクをリアルタイム表示',
|
||||
guideDescSidechat: '現在のセッションの文脈を引き継ぐ独立した追加スレッド',
|
||||
guideDescTerminal: '本物のシェル(xterm + pty)、再接続対応',
|
||||
guideDescBrowser: '埋め込みブラウザ。チャット内のリンクも引き受け可能',
|
||||
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
|
||||
changesRead: '読み取り',
|
||||
changesMdReading: '閲覧',
|
||||
@@ -81,7 +87,6 @@ export const ja: Record<string, string> = {
|
||||
closeOtherTabs: '他のタブを閉じる',
|
||||
closeLeftTabs: '左のタブを閉じる',
|
||||
closeRightTabs: '右のタブを閉じる',
|
||||
moveToFreeWindow: 'フリーウィンドウへ移動',
|
||||
floatDropHint: '離すとフリーウィンドウで開きます',
|
||||
dockToSidebar: 'サイドバーへ戻す',
|
||||
pinTerminal: 'ターミナルを固定',
|
||||
@@ -96,8 +101,6 @@ export const ja: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'グローバルに固定',
|
||||
pinnedRailLabel: '固定されたターミナル',
|
||||
closePinnedTerminal: 'ターミナルを閉じる',
|
||||
collapse: 'サイドバーを折りたたむ',
|
||||
expand: 'サイドバーを展開',
|
||||
collapseBottomPanel: '下パネルを折りたたむ',
|
||||
expandBottomPanel: '下パネルを展開',
|
||||
terminalError: 'ターミナル接続に失敗',
|
||||
@@ -155,12 +158,6 @@ export const ja: Record<string, string> = {
|
||||
changesOpenDiffTab: '独立タブで開く',
|
||||
changesClosePreview: 'プレビューを閉じる',
|
||||
changesResizePreview: 'プレビューの高さを変更',
|
||||
changesDiffOpenTitle: 'diff の展開先',
|
||||
changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
|
||||
changesDiffOpenFloat: 'フリーウィンドウ',
|
||||
changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
|
||||
changesDiffOpenPane: 'ペイン',
|
||||
changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
|
||||
changesLoadError: 'セッションのファイル記録は現在利用できません',
|
||||
staged: 'ステージ済み',
|
||||
unstaged: '未ステージ',
|
||||
@@ -234,13 +231,6 @@ export const ja: Record<string, string> = {
|
||||
settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理',
|
||||
settingsPopupDesc: '「{feature}」の関連オプションを設定',
|
||||
settingsDone: '完了',
|
||||
settingsOpenTitle: '新規会話でデフォルト展開',
|
||||
settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持',
|
||||
settingsWidthTitle: 'デフォルト幅の比率',
|
||||
settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
|
||||
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
|
||||
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
|
||||
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
|
||||
settingsTitleBarTitle: '位置互換モード',
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
export const ko: Record<string, string> = {
|
||||
files: '파일',
|
||||
guideDescFiles: '워크스페이스 파일 트리를 보고 클릭하면 미리보기/편집',
|
||||
guideDescGit: 'Git 변경 사항과 이번 세션의 파일 변경을 한 탭에서',
|
||||
guideDescSubagent: '하위 에이전트 구조와 백그라운드 작업을 실시간으로',
|
||||
guideDescSidechat: '현재 세션의 컨텍스트를 이어받는 독립 후속 스레드',
|
||||
guideDescTerminal: '실제 셸(xterm + pty), 재연결 지원',
|
||||
guideDescBrowser: '내장 브라우저, 채팅 링크도 대신 열 수 있음',
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
@@ -73,7 +79,6 @@ export const ko: Record<string, string> = {
|
||||
closeOtherTabs: '다른 탭 닫기',
|
||||
closeLeftTabs: '왼쪽 탭 닫기',
|
||||
closeRightTabs: '오른쪽 탭 닫기',
|
||||
moveToFreeWindow: '자유 창으로 이동',
|
||||
floatDropHint: '놓으면 자유 창에서 열립니다',
|
||||
dockToSidebar: '사이드바로 돌아가기',
|
||||
pinTerminal: '터미널 고정',
|
||||
@@ -88,8 +93,6 @@ export const ko: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: '전역에 고정됨',
|
||||
pinnedRailLabel: '고정된 터미널',
|
||||
closePinnedTerminal: '터미널 닫기',
|
||||
collapse: '사이드바 접기',
|
||||
expand: '사이드바 펼치기',
|
||||
collapseBottomPanel: '하단 패널 접기',
|
||||
expandBottomPanel: '하단 패널 펼치기',
|
||||
terminalError: '터미널 연결 실패',
|
||||
@@ -147,12 +150,6 @@ export const ko: Record<string, string> = {
|
||||
changesOpenDiffTab: '별도 탭에서 열기',
|
||||
changesClosePreview: '미리보기 닫기',
|
||||
changesResizePreview: '미리보기 높이 조정',
|
||||
changesDiffOpenTitle: 'diff 열기 위치',
|
||||
changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
|
||||
changesDiffOpenFloat: '자유 창',
|
||||
changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
|
||||
changesDiffOpenPane: '패널',
|
||||
changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
|
||||
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
|
||||
staged: '스테이징됨',
|
||||
unstaged: '스테이징 안 됨',
|
||||
@@ -226,13 +223,6 @@ export const ko: Record<string, string> = {
|
||||
settingsIntro: '사이드 카드의 표시 내용과 기본 동작을 관리합니다',
|
||||
settingsPopupDesc: '"{feature}" 관련 옵션을 구성합니다',
|
||||
settingsDone: '완료',
|
||||
settingsOpenTitle: '새 대화 기본 열림',
|
||||
settingsOpenDesc: '새 대화를 만들 때 사이드 카드를 자동으로 펼칩니다. 기존 대화는 각자의 레이아웃을 유지합니다',
|
||||
settingsWidthTitle: '기본 너비 비율',
|
||||
settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
|
||||
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
|
||||
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
|
||||
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
|
||||
settingsTitleBarTitle: '위치 호환 모드',
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
*/
|
||||
export const nl: Record<string, string> = {
|
||||
files: 'Bestanden',
|
||||
guideDescFiles: 'Blader door de bestandsboom van de workspace; klik opent voorbeeld of editor',
|
||||
guideDescGit: 'Git-wijzigingen en bestandswijzigingen van deze sessie in één tabblad',
|
||||
guideDescSubagent: 'Subagent-topologie en achtergrondtaken, live',
|
||||
guideDescSidechat: 'Een onafhankelijke vervolgthread met de context van deze sessie',
|
||||
guideDescTerminal: 'Een echte shell (xterm + pty) met herverbinding',
|
||||
guideDescBrowser: 'Ingebouwde browser die chatlinks kan overnemen',
|
||||
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
|
||||
changesRead: 'Lezen',
|
||||
changesMdReading: 'Lezen',
|
||||
@@ -79,7 +85,6 @@ 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',
|
||||
pinTerminal: 'Terminal vastmaken',
|
||||
@@ -94,8 +99,6 @@ export const nl: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Wereldwijd vastgemaakt',
|
||||
pinnedRailLabel: 'Vastgemaakte terminals',
|
||||
closePinnedTerminal: 'Terminal sluiten',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapseBottomPanel: 'Onderpaneel inklappen',
|
||||
expandBottomPanel: 'Onderpaneel uitklappen',
|
||||
terminalError: 'Terminalverbinding mislukt',
|
||||
@@ -153,12 +156,6 @@ export const nl: Record<string, string> = {
|
||||
changesOpenDiffTab: 'In eigen diff-tabblad openen',
|
||||
changesClosePreview: 'Voorbeeld sluiten',
|
||||
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
|
||||
changesDiffOpenTitle: 'Diff opent als',
|
||||
changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst',
|
||||
changesDiffOpenFloat: 'Vrij venster',
|
||||
changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden',
|
||||
changesDiffOpenPane: 'Paneel',
|
||||
changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)',
|
||||
changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar',
|
||||
staged: 'Gestaged',
|
||||
unstaged: 'Niet gestaged',
|
||||
@@ -232,13 +229,6 @@ export const nl: Record<string, string> = {
|
||||
settingsIntro: 'Beheer wat de zijkaart toont en hoe deze zich gedraagt',
|
||||
settingsPopupDesc: 'Gerelateerde opties voor {feature} configureren',
|
||||
settingsDone: 'Klaar',
|
||||
settingsOpenTitle: 'Standaard openen voor nieuwe conversaties',
|
||||
settingsOpenDesc: 'De zijkaart automatisch uitklappen voor gloednieuwe conversaties; bestaande conversaties behouden hun eigen layout',
|
||||
settingsWidthTitle: 'Standaard breedte-aandeel',
|
||||
settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
|
||||
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
|
||||
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
|
||||
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
|
||||
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const pl: Record<string, string> = {
|
||||
files: 'Pliki',
|
||||
guideDescFiles: 'Przeglądaj drzewo plików obszaru roboczego; klik otwiera podgląd lub edycję',
|
||||
guideDescGit: 'Zmiany Git i zmiany plików z tej sesji w jednej karcie',
|
||||
guideDescSubagent: 'Topologia subagentów i zadania w tle na żywo',
|
||||
guideDescSidechat: 'Niezależny wątek uzupełniający dziedziczący kontekst tej sesji',
|
||||
guideDescTerminal: 'Prawdziwy shell (xterm + pty) z ponownym łączeniem',
|
||||
guideDescBrowser: 'Wbudowana przeglądarka, może przejmować linki z czatu',
|
||||
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
|
||||
changesRead: 'Odczyt',
|
||||
changesMdReading: 'Czytanie',
|
||||
@@ -83,7 +89,6 @@ 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',
|
||||
pinTerminal: 'Przypnij Terminal',
|
||||
@@ -98,8 +103,6 @@ export const pl: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Przypięty globalnie',
|
||||
pinnedRailLabel: 'Przypięte terminale',
|
||||
closePinnedTerminal: 'Zamknij terminal',
|
||||
collapse: 'Zwiń panel boczny',
|
||||
expand: 'Rozwiń panel boczny',
|
||||
collapseBottomPanel: 'Zwiń panel dolny',
|
||||
expandBottomPanel: 'Rozwiń panel dolny',
|
||||
terminalError: 'Połączenie z terminalem nie powiodło się',
|
||||
@@ -157,12 +160,6 @@ export const pl: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Otwórz w osobnej karcie',
|
||||
changesClosePreview: 'Zamknij podgląd',
|
||||
changesResizePreview: 'Zmień wysokość podglądu',
|
||||
changesDiffOpenTitle: 'Diff otwiera się jako',
|
||||
changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff',
|
||||
changesDiffOpenFloat: 'Odrębne okno',
|
||||
changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu',
|
||||
changesDiffOpenPane: 'Panel',
|
||||
changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)',
|
||||
changesLoadError: 'Zapisy plików sesji są teraz niedostępne',
|
||||
staged: 'W indeksie',
|
||||
unstaged: 'Poza indeksem',
|
||||
@@ -236,13 +233,6 @@ export const pl: Record<string, string> = {
|
||||
settingsIntro: 'Zarządzaj tym, co pokazuje karta boczna i jak się zachowuje',
|
||||
settingsPopupDesc: 'Skonfiguruj opcje powiązane z: {feature}',
|
||||
settingsDone: 'Gotowe',
|
||||
settingsOpenTitle: 'Otwórz domyślnie dla nowych rozmów',
|
||||
settingsOpenDesc: 'Rozwiń kartę boczną automatycznie dla zupełnie nowych rozmów; istniejące rozmowy zachowują własne układy',
|
||||
settingsWidthTitle: 'Domyślny udział szerokości',
|
||||
settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
|
||||
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
|
||||
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
|
||||
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
|
||||
settingsTitleBarTitle: 'Tryb zgodności pozycji',
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export const pt: Record<string, string> = {
|
||||
files: 'Arquivos',
|
||||
guideDescFiles: 'Navegue pela árvore do workspace; um clique abre a pré-visualização ou a edição',
|
||||
guideDescGit: 'Alterações do Git e edições desta sessão em um único separador',
|
||||
guideDescSubagent: 'Topologia de subagentes e tarefas em segundo plano, ao vivo',
|
||||
guideDescSidechat: 'Uma conversa paralela independente que herda o contexto desta sessão',
|
||||
guideDescTerminal: 'Um shell real (xterm + pty) com reconexão',
|
||||
guideDescBrowser: 'Navegador incorporado, que pode abrir links do chat',
|
||||
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
|
||||
changesRead: 'Leitura',
|
||||
changesMdReading: 'Leitura',
|
||||
@@ -64,7 +70,6 @@ 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',
|
||||
pinTerminal: 'Fixar Terminal',
|
||||
@@ -79,8 +84,6 @@ export const pt: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Fixado globalmente',
|
||||
pinnedRailLabel: 'Terminais Fixados',
|
||||
closePinnedTerminal: 'Fechar Terminal',
|
||||
collapse: 'Recolher barra lateral',
|
||||
expand: 'Expandir barra lateral',
|
||||
collapseBottomPanel: 'Recolher painel inferior',
|
||||
expandBottomPanel: 'Expandir painel inferior',
|
||||
terminalError: 'Falha na conexão do terminal',
|
||||
@@ -138,12 +141,6 @@ export const pt: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Abrir em uma aba de diff',
|
||||
changesClosePreview: 'Fechar prévia',
|
||||
changesResizePreview: 'Ajustar altura da prévia',
|
||||
changesDiffOpenTitle: 'O diff abre como',
|
||||
changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff',
|
||||
changesDiffOpenFloat: 'Janela livre',
|
||||
changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo',
|
||||
changesDiffOpenPane: 'Painel',
|
||||
changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)',
|
||||
changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento',
|
||||
staged: 'Preparadas',
|
||||
unstaged: 'Não preparadas',
|
||||
@@ -217,13 +214,6 @@ export const pt: Record<string, string> = {
|
||||
settingsIntro: 'Gerencie o que o cartão lateral mostra e como ele se comporta',
|
||||
settingsPopupDesc: 'Configurar opções relacionadas para {feature}',
|
||||
settingsDone: 'Concluído',
|
||||
settingsOpenTitle: 'Abrir por padrão em novas conversas',
|
||||
settingsOpenDesc: 'Expandir o cartão lateral automaticamente em conversas totalmente novas; conversas existentes mantêm seus próprios layouts',
|
||||
settingsWidthTitle: 'Participação padrão na largura',
|
||||
settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
|
||||
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
|
||||
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
|
||||
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
|
||||
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
|
||||
export const ru: Record<string, string> = {
|
||||
files: 'Файлы',
|
||||
guideDescFiles: 'Дерево файлов рабочей области: клик открывает просмотр или редактор',
|
||||
guideDescGit: 'Изменения Git и правки файлов этой сессии в одной вкладке',
|
||||
guideDescSubagent: 'Топология субагентов и фоновые задачи в реальном времени',
|
||||
guideDescSidechat: 'Независимый дополнительный тред с контекстом этой сессии',
|
||||
guideDescTerminal: 'Настоящий shell (xterm + pty) с переподключением',
|
||||
guideDescBrowser: 'Встроенный браузер, перехватывает ссылки из чата',
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
@@ -77,7 +83,6 @@ export const ru: Record<string, string> = {
|
||||
closeOtherTabs: 'Закрыть остальные вкладки',
|
||||
closeLeftTabs: 'Закрыть вкладки слева',
|
||||
closeRightTabs: 'Закрыть вкладки справа',
|
||||
moveToFreeWindow: 'Переместить в свободное окно',
|
||||
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
|
||||
dockToSidebar: 'Вернуть на боковую панель',
|
||||
pinTerminal: 'Закрепить терминал',
|
||||
@@ -92,8 +97,6 @@ export const ru: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Закреплён глобально',
|
||||
pinnedRailLabel: 'Закреплённые терминалы',
|
||||
closePinnedTerminal: 'Закрыть терминал',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapseBottomPanel: 'Свернуть нижнюю панель',
|
||||
expandBottomPanel: 'Развернуть нижнюю панель',
|
||||
terminalError: 'Сбой подключения терминала',
|
||||
@@ -151,12 +154,6 @@ export const ru: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Открыть в отдельной вкладке',
|
||||
changesClosePreview: 'Закрыть просмотр',
|
||||
changesResizePreview: 'Изменить высоту просмотра',
|
||||
changesDiffOpenTitle: 'Diff открывается как',
|
||||
changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
|
||||
changesDiffOpenFloat: 'Свободное окно',
|
||||
changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
|
||||
changesDiffOpenPane: 'Панель',
|
||||
changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
|
||||
changesLoadError: 'Записи файлов сессии сейчас недоступны',
|
||||
staged: 'В индексе',
|
||||
unstaged: 'Не в индексе',
|
||||
@@ -231,13 +228,6 @@ export const ru: Record<string, string> = {
|
||||
settingsIntro: 'Управление содержимым и поведением боковой карточки по умолчанию',
|
||||
settingsPopupDesc: 'Настройка параметров «{feature}»',
|
||||
settingsDone: 'Готово',
|
||||
settingsOpenTitle: 'Открывать по умолчанию для новых сессий',
|
||||
settingsOpenDesc: 'Автоматически разворачивать боковую карточку для новых сессий; у существующих сессий сохраняется их собственная раскладка',
|
||||
settingsWidthTitle: 'Доля ширины по умолчанию',
|
||||
settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
|
||||
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
|
||||
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
|
||||
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
|
||||
settingsTitleBarTitle: 'Режим совместимости заголовка',
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export const sv: Record<string, string> = {
|
||||
files: 'Filer',
|
||||
guideDescFiles: 'Bläddra i arbetsytans filträd; klick öppnar förhandsvisning eller redigering',
|
||||
guideDescGit: 'Git-ändringar och sessionens filändringar i en flik',
|
||||
guideDescSubagent: 'Subagenttopologi och bakgrundsjobb, live',
|
||||
guideDescSidechat: 'En oberoende uppföljningstråd som ärver sessionens kontext',
|
||||
guideDescTerminal: 'Ett riktigt skal (xterm + pty) med återanslutning',
|
||||
guideDescBrowser: 'Inbäddad webbläsare som kan ta över chattlänkar',
|
||||
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
|
||||
changesRead: 'Läs',
|
||||
changesMdReading: 'Läsning',
|
||||
@@ -64,7 +70,6 @@ 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',
|
||||
pinTerminal: 'Fäst Terminal',
|
||||
@@ -79,8 +84,6 @@ export const sv: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Fäst globalt',
|
||||
pinnedRailLabel: 'Fästa terminaler',
|
||||
closePinnedTerminal: 'Stäng terminal',
|
||||
collapse: 'Komprimera sidopanel',
|
||||
expand: 'Expandera sidopanel',
|
||||
collapseBottomPanel: 'Komprimera bottenpanel',
|
||||
expandBottomPanel: 'Expandera bottenpanel',
|
||||
terminalError: 'Terminalanslutning misslyckades',
|
||||
@@ -138,12 +141,6 @@ export const sv: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Öppna i egen diff-flik',
|
||||
changesClosePreview: 'Stäng förhandsvisning',
|
||||
changesResizePreview: 'Ändra storlek på förhandsvisningen',
|
||||
changesDiffOpenTitle: 'Diff öppnas som',
|
||||
changesDiffOpenDesc: 'Var åtgärden "öppna i egen diff-flik" placerar diffen',
|
||||
changesDiffOpenFloat: 'Fritt fönster',
|
||||
changesDiffOpenFloatDesc: 'Ett flytande fönster centrerat i vyn — dra, ändra storlek, håll överst',
|
||||
changesDiffOpenPane: 'Panel',
|
||||
changesDiffOpenPaneDesc: 'Dockad under källpanelen (VSCode-liknande diff-delning)',
|
||||
changesLoadError: 'Sessionens filposter är inte tillgängliga just nu',
|
||||
staged: 'Köade',
|
||||
unstaged: 'Avköade',
|
||||
@@ -217,13 +214,6 @@ export const sv: Record<string, string> = {
|
||||
settingsIntro: 'Hantera vad sidokortet visar och hur det beter sig',
|
||||
settingsPopupDesc: 'Konfigurera relaterade alternativ för {feature}',
|
||||
settingsDone: 'Klar',
|
||||
settingsOpenTitle: 'Öppna som standard för nya konversationer',
|
||||
settingsOpenDesc: 'Expandera sidokortet automatiskt för helt nya konversationer; befintliga konversationer behåller sina egna layouter',
|
||||
settingsWidthTitle: 'Standardbreddandel',
|
||||
settingsWidthDesc: 'Sidokortets standardandel av fönsterbredden för nya konversationer (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Öppna chattfiler i sidopanelen',
|
||||
settingsOpenPathDesc: 'Öppna fillänkar i chatten (verktygsrader, producerade filer, omnämnanden) i sidopanelens editor i stället för systemets standardapp',
|
||||
settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen',
|
||||
settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)',
|
||||
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const th: Record<string, string> = {
|
||||
files: 'ไฟล์',
|
||||
guideDescFiles: 'เรียกดูแผนผังไฟล์ของเวิร์กสเปซ คลิกเพื่อดูตัวอย่างหรือแก้ไข',
|
||||
guideDescGit: 'การเปลี่ยนแปลง Git และไฟล์ที่แก้ในเซสชันนี้ในแท็บเดียว',
|
||||
guideDescSubagent: 'โครงสร้างซับเอเจนต์และงานเบื้องหลังแบบเรียลไทม์',
|
||||
guideDescSidechat: 'เธรดถามต่ออิสระที่สืบทอดบริบทของเซสชันนี้',
|
||||
guideDescTerminal: 'เชลล์จริง (xterm + pty) เชื่อมต่อใหม่ได้',
|
||||
guideDescBrowser: 'เบราว์เซอร์ในตัว รับช่วงลิงก์จากแชทได้',
|
||||
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
|
||||
changesRead: 'อ่าน',
|
||||
changesMdReading: 'อ่าน',
|
||||
@@ -81,7 +87,6 @@ export const th: Record<string, string> = {
|
||||
closeOtherTabs: 'ปิด Tab อื่น',
|
||||
closeLeftTabs: 'ปิด Tab ด้านซ้าย',
|
||||
closeRightTabs: 'ปิด Tab ด้านขวา',
|
||||
moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ',
|
||||
floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ',
|
||||
dockToSidebar: 'กลับไปที่แถบข้าง',
|
||||
pinTerminal: 'ปักหมุดเทอร์มินัล',
|
||||
@@ -96,8 +101,6 @@ export const th: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'ปักหมุดแบบ Global',
|
||||
pinnedRailLabel: 'เทอร์มินัลที่ปักหมุด',
|
||||
closePinnedTerminal: 'ปิดเทอร์มินัล',
|
||||
collapse: 'ย่อแถบด้านข้าง',
|
||||
expand: 'ขยายแถบด้านข้าง',
|
||||
collapseBottomPanel: 'ย่อพาเนลด้านล่าง',
|
||||
expandBottomPanel: 'ขยายพาเนลด้านล่าง',
|
||||
terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว',
|
||||
@@ -155,12 +158,6 @@ export const th: Record<string, string> = {
|
||||
changesOpenDiffTab: 'เปิดในแท็บ diff แยก',
|
||||
changesClosePreview: 'ปิดตัวอย่าง',
|
||||
changesResizePreview: 'ปรับขนาดตัวอย่าง',
|
||||
changesDiffOpenTitle: 'diff เปิดเป็น',
|
||||
changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด',
|
||||
changesDiffOpenFloat: 'หน้าต่างลอย',
|
||||
changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด',
|
||||
changesDiffOpenPane: 'แพเนล',
|
||||
changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)',
|
||||
changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
@@ -234,13 +231,6 @@ export const th: Record<string, string> = {
|
||||
settingsIntro: 'จัดการสิ่งที่การ์ดด้านข้างแสดงและพฤติกรรมของมัน',
|
||||
settingsPopupDesc: 'กำหนดค่าตัวเลือกที่เกี่ยวข้องสำหรับ {feature}',
|
||||
settingsDone: 'เสร็จสิ้น',
|
||||
settingsOpenTitle: 'เปิดโดยค่าเริ่มต้นสำหรับแชทใหม่',
|
||||
settingsOpenDesc: 'ขยายการ์ดด้านข้างโดยอัตโนมัติสำหรับแชทใหม่; แชทที่มีอยู่แล้วยังคงเค้าโครงของตัวเอง',
|
||||
settingsWidthTitle: 'สัดส่วนความกว้างเริ่มต้น',
|
||||
settingsWidthDesc: 'สัดส่วนความกว้างเริ่มต้นของการ์ดด้านข้างจากความกว้างหน้าต่างสำหรับแชทใหม่ (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
|
||||
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
|
||||
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
|
||||
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
|
||||
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const tr: Record<string, string> = {
|
||||
files: 'Dosyalar',
|
||||
guideDescFiles: 'Çalışma alanı dosya ağacına göz atın; tıklayınca önizleme veya düzenleme açılır',
|
||||
guideDescGit: 'Git değişiklikleri ve bu oturumun dosya değişiklikleri tek sekmede',
|
||||
guideDescSubagent: 'Alt ajan topolojisi ve arka plan görevleri, canlı',
|
||||
guideDescSidechat: 'Bu oturumun bağlamını devralan bağımsız bir yan konu',
|
||||
guideDescTerminal: 'Gerçek bir shell (xterm + pty), yeniden bağlanma destekli',
|
||||
guideDescBrowser: 'Sohbet bağlantılarını devralabilen gömülü tarayıcı',
|
||||
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
|
||||
changesRead: 'Okuma',
|
||||
changesMdReading: 'Okuma',
|
||||
@@ -81,7 +87,6 @@ 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',
|
||||
pinTerminal: 'Terminali Sabitle',
|
||||
@@ -96,8 +101,6 @@ export const tr: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Genel olarak sabitlendi',
|
||||
pinnedRailLabel: 'Sabitlenmiş Terminaller',
|
||||
closePinnedTerminal: 'Terminali Kapat',
|
||||
collapse: 'Kenar çubuğunu daralt',
|
||||
expand: 'Kenar çubuğunu genişlet',
|
||||
collapseBottomPanel: 'Alt paneli daralt',
|
||||
expandBottomPanel: 'Alt paneli genişlet',
|
||||
terminalError: 'Terminal bağlantısı başarısız',
|
||||
@@ -155,12 +158,6 @@ export const tr: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç',
|
||||
changesClosePreview: 'Önizlemeyi kapat',
|
||||
changesResizePreview: 'Önizlemeyi boyutlandır',
|
||||
changesDiffOpenTitle: 'diff açılır',
|
||||
changesDiffOpenDesc: '"Ayrı bir diff sekmesinde aç" eylemi diff\'i nereye koyar',
|
||||
changesDiffOpenFloat: 'Serbest pencere',
|
||||
changesDiffOpenFloatDesc: 'Görünümün ortasında yüzen pencere — sürükle, boyutlandır, üstte tut',
|
||||
changesDiffOpenPane: 'Panel',
|
||||
changesDiffOpenPaneDesc: 'Kaynak panelin altına sabitlenmiş (VSCode tarzı diff bölmesi)',
|
||||
changesLoadError: 'Oturum dosya kayıtları şu anda kullanılamıyor',
|
||||
staged: 'Sahnelendi',
|
||||
unstaged: 'Sahnelenmedi',
|
||||
@@ -234,13 +231,6 @@ export const tr: Record<string, string> = {
|
||||
settingsIntro: 'Yan kartın ne gösterdiğini ve nasıl davrandığını yönetin',
|
||||
settingsPopupDesc: '«{feature}» için ilgili seçenekleri yapılandırın',
|
||||
settingsDone: 'Bitti',
|
||||
settingsOpenTitle: 'Yeni sohbetlerde varsayılan olarak aç',
|
||||
settingsOpenDesc: 'Yepyeni sohbetler için yan kartı otomatik genişlet; var olan sohbetler kendi düzenlerini korur',
|
||||
settingsWidthTitle: 'Varsayılan genişlik payı',
|
||||
settingsWidthDesc: 'Yeni sohbetler için yan kartın pencere genişliğindeki varsayılan payı (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Sohbet dosyalarını kenar çubuğunda aç',
|
||||
settingsOpenPathDesc: 'Sohbetteki dosya bağlantılarını (araç satırları, üretilen dosyalar, anılmalar) sistem varsayılan uygulaması yerine kenar çubuğu düzenleyicisinde aç',
|
||||
settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et',
|
||||
settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)',
|
||||
settingsTitleBarTitle: 'Konum uyumluluk modu',
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const vi: Record<string, string> = {
|
||||
files: 'Tệp',
|
||||
guideDescFiles: 'Duyệt cây tệp của workspace; bấm để xem trước hoặc chỉnh sửa',
|
||||
guideDescGit: 'Thay đổi Git và thay đổi tệp của phiên này trong một tab',
|
||||
guideDescSubagent: 'Sơ đồ subagent và tác vụ nền, theo thời gian thực',
|
||||
guideDescSidechat: 'Luồng hỏi tiếp độc lập kế thừa ngữ cảnh của phiên này',
|
||||
guideDescTerminal: 'Shell thật (xterm + pty), tự kết nối lại',
|
||||
guideDescBrowser: 'Trình duyệt nhúng, có thể mở link trong chat',
|
||||
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
|
||||
changesRead: 'Đọc',
|
||||
changesMdReading: 'Đọc',
|
||||
@@ -81,7 +87,6 @@ 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',
|
||||
pinTerminal: 'Ghim Terminal',
|
||||
@@ -96,8 +101,6 @@ export const vi: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Đã ghim toàn cục',
|
||||
pinnedRailLabel: 'Terminal đã ghim',
|
||||
closePinnedTerminal: 'Đóng Terminal',
|
||||
collapse: 'Thu gọn thanh bên',
|
||||
expand: 'Mở rộng thanh bên',
|
||||
collapseBottomPanel: 'Thu gọn panel dưới',
|
||||
expandBottomPanel: 'Mở rộng panel dưới',
|
||||
terminalError: 'Kết nối terminal thất bại',
|
||||
@@ -155,12 +158,6 @@ export const vi: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Mở trong tab diff riêng',
|
||||
changesClosePreview: 'Đóng xem trước',
|
||||
changesResizePreview: 'Điều chỉnh kích thước xem trước',
|
||||
changesDiffOpenTitle: 'diff mở thành',
|
||||
changesDiffOpenDesc: 'Hành động «mở trong tab diff riêng» đặt diff ở đâu',
|
||||
changesDiffOpenFloat: 'Cửa sổ tự do',
|
||||
changesDiffOpenFloatDesc: 'Cửa sổ nổi giữa khung nhìn — kéo, đổi kích thước, ghim trên cùng',
|
||||
changesDiffOpenPane: 'Bảng',
|
||||
changesDiffOpenPaneDesc: 'Gắn dưới bảng nguồn (chia diff kiểu VSCode)',
|
||||
changesLoadError: 'Bản ghi tệp phiên hiện không khả dụng',
|
||||
staged: 'Đã stage',
|
||||
unstaged: 'Chưa stage',
|
||||
@@ -234,13 +231,6 @@ export const vi: Record<string, string> = {
|
||||
settingsIntro: 'Quản lý nội dung hiển thị và hành vi mặc định của thẻ bên',
|
||||
settingsPopupDesc: 'Cấu hình tùy chọn cho «{feature}»',
|
||||
settingsDone: 'Xong',
|
||||
settingsOpenTitle: 'Mở mặc định cho phiên mới',
|
||||
settingsOpenDesc: 'Tự động mở rộng thẻ bên cho phiên mới; phiên đã có giữ nguyên bố cục',
|
||||
settingsWidthTitle: 'Tỷ lệ chiều rộng mặc định',
|
||||
settingsWidthDesc: 'Tỷ lệ phần trăm chiều rộng cửa sổ mà thẻ bên chiếm cho phiên mới (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Mở tệp chat trong thanh bên',
|
||||
settingsOpenPathDesc: 'Khi nhấp link tệp trong chat (dòng công cụ, danh sách sản phẩm, nhắc tệp), mở trong trình soạn thảo thanh bên thay vì ứng dụng mặc định hệ thống',
|
||||
settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình',
|
||||
settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)',
|
||||
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const zhHK: Record<string, string> = {
|
||||
files: '檔案',
|
||||
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
|
||||
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
|
||||
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
|
||||
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
|
||||
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
|
||||
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
@@ -96,7 +102,6 @@ export const zhHK: Record<string, string> = {
|
||||
closeOtherTabs: '關閉其他標籤',
|
||||
closeLeftTabs: '關閉左側標籤',
|
||||
closeRightTabs: '關閉右側標籤',
|
||||
moveToFreeWindow: '移動到自由視窗',
|
||||
floatDropHint: '放開以在自由視窗中開啟',
|
||||
dockToSidebar: '返回側邊欄',
|
||||
pinTerminal: '固定終端',
|
||||
@@ -111,8 +116,6 @@ export const zhHK: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: '固定到全域',
|
||||
pinnedRailLabel: '固定終端',
|
||||
closePinnedTerminal: '關閉終端',
|
||||
collapse: '收起側邊欄',
|
||||
expand: '展開側邊欄',
|
||||
collapseBottomPanel: '收起底部面板',
|
||||
expandBottomPanel: '展開底部面板',
|
||||
terminalError: '終端連線失敗',
|
||||
@@ -170,12 +173,6 @@ export const zhHK: Record<string, string> = {
|
||||
changesOpenDiffTab: '在獨立分頁中開啟',
|
||||
changesClosePreview: '關閉預覽',
|
||||
changesResizePreview: '調整預覽高度',
|
||||
changesDiffOpenTitle: '差異展開方式',
|
||||
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
|
||||
changesDiffOpenFloat: '浮動視窗',
|
||||
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
|
||||
changesLoadError: '工作階段檔案記錄暫不可用',
|
||||
staged: '已暫存',
|
||||
unstaged: '未暫存',
|
||||
@@ -249,13 +246,6 @@ export const zhHK: Record<string, string> = {
|
||||
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
|
||||
settingsPopupDesc: '為「{feature}」設定相關選項',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新工作階段預設開啟',
|
||||
settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
|
||||
settingsWidthTitle: '預設寬度佔比',
|
||||
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||
settingsTitleBarTitle: '位置相容模式',
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const zhMO: Record<string, string> = {
|
||||
files: '檔案',
|
||||
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
|
||||
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
|
||||
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
|
||||
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
|
||||
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
|
||||
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
@@ -96,7 +102,6 @@ export const zhMO: Record<string, string> = {
|
||||
closeOtherTabs: '關閉其他標籤',
|
||||
closeLeftTabs: '關閉左側標籤',
|
||||
closeRightTabs: '關閉右側標籤',
|
||||
moveToFreeWindow: '移動到自由視窗',
|
||||
floatDropHint: '放開以在自由視窗中開啟',
|
||||
dockToSidebar: '返回側邊欄',
|
||||
pinTerminal: '固定終端',
|
||||
@@ -111,8 +116,6 @@ export const zhMO: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: '固定到全域',
|
||||
pinnedRailLabel: '固定終端',
|
||||
closePinnedTerminal: '關閉終端',
|
||||
collapse: '收起側邊欄',
|
||||
expand: '展開側邊欄',
|
||||
collapseBottomPanel: '收起底部面板',
|
||||
expandBottomPanel: '展開底部面板',
|
||||
terminalError: '終端連線失敗',
|
||||
@@ -170,12 +173,6 @@ export const zhMO: Record<string, string> = {
|
||||
changesOpenDiffTab: '在獨立分頁中開啟',
|
||||
changesClosePreview: '關閉預覽',
|
||||
changesResizePreview: '調整預覽高度',
|
||||
changesDiffOpenTitle: '差異展開方式',
|
||||
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
|
||||
changesDiffOpenFloat: '浮動視窗',
|
||||
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
|
||||
changesLoadError: '工作階段檔案記錄暫不可用',
|
||||
staged: '已暫存',
|
||||
unstaged: '未暫存',
|
||||
@@ -249,13 +246,6 @@ export const zhMO: Record<string, string> = {
|
||||
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
|
||||
settingsPopupDesc: '為「{feature}」設定相關選項',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新工作階段預設開啟',
|
||||
settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
|
||||
settingsWidthTitle: '預設寬度佔比',
|
||||
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||
settingsTitleBarTitle: '位置相容模式',
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const zhTW: Record<string, string> = {
|
||||
files: '檔案',
|
||||
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
|
||||
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
|
||||
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
|
||||
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
|
||||
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
|
||||
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
|
||||
changesSessionEmpty: '本作業階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
@@ -96,7 +102,6 @@ export const zhTW: Record<string, string> = {
|
||||
closeOtherTabs: '關閉其他標籤',
|
||||
closeLeftTabs: '關閉左側標籤',
|
||||
closeRightTabs: '關閉右側標籤',
|
||||
moveToFreeWindow: '移動到自由視窗',
|
||||
floatDropHint: '放開以在自由視窗中開啟',
|
||||
dockToSidebar: '返回側邊欄',
|
||||
pinTerminal: '固定終端',
|
||||
@@ -111,8 +116,6 @@ export const zhTW: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: '固定到全域',
|
||||
pinnedRailLabel: '固定終端',
|
||||
closePinnedTerminal: '關閉終端',
|
||||
collapse: '收起側邊欄',
|
||||
expand: '展開側邊欄',
|
||||
collapseBottomPanel: '收起底部面板',
|
||||
expandBottomPanel: '展開底部面板',
|
||||
terminalError: '終端連線失敗',
|
||||
@@ -170,12 +173,6 @@ export const zhTW: Record<string, string> = {
|
||||
changesOpenDiffTab: '在獨立頁籤中開啟',
|
||||
changesClosePreview: '關閉預覽',
|
||||
changesResizePreview: '調整預覽高度',
|
||||
changesDiffOpenTitle: '差異展開方式',
|
||||
changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡',
|
||||
changesDiffOpenFloat: '浮動視窗',
|
||||
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
|
||||
changesLoadError: '工作階段檔案記錄暫不可用',
|
||||
staged: '已暫存',
|
||||
unstaged: '未暫存',
|
||||
@@ -249,13 +246,6 @@ export const zhTW: Record<string, string> = {
|
||||
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
|
||||
settingsPopupDesc: '為「{feature}」設定相關選項',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新工作階段預設開啟',
|
||||
settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
|
||||
settingsWidthTitle: '預設寬度佔比',
|
||||
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||
settingsTitleBarTitle: '位置相容模式',
|
||||
|
||||
+12
-32
@@ -24,6 +24,12 @@
|
||||
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
|
||||
export const zh = {
|
||||
files: '文件',
|
||||
guideDescFiles: '浏览工作区文件树,点开即预览或编辑',
|
||||
guideDescGit: 'Git 变更与本轮文件改动,双视角合一',
|
||||
guideDescSubagent: '子代理拓扑与后台任务,实时状态',
|
||||
guideDescSidechat: '继承当前会话上下文的独立追问线程',
|
||||
guideDescTerminal: '真实 shell 终端(xterm + pty),断线重连',
|
||||
guideDescBrowser: '内嵌网页浏览器,可接管聊天里的外链',
|
||||
changesSessionEmpty: '本会话还没有文件操作',
|
||||
changesRead: '读取',
|
||||
changesMdReading: '阅读',
|
||||
@@ -93,7 +99,6 @@ export const zh = {
|
||||
closeOtherTabs: '关闭其他页签',
|
||||
closeLeftTabs: '关闭左侧页签',
|
||||
closeRightTabs: '关闭右侧页签',
|
||||
moveToFreeWindow: '移动到自由窗口',
|
||||
floatDropHint: '松开以在自由窗口中打开',
|
||||
dockToSidebar: '回到侧边栏',
|
||||
pinTerminal: '固定终端',
|
||||
@@ -108,8 +113,6 @@ export const zh = {
|
||||
pinnedTerminalScopeGlobal: '固定到全局',
|
||||
pinnedRailLabel: '固定终端',
|
||||
closePinnedTerminal: '关闭终端',
|
||||
collapse: '折叠侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
collapseBottomPanel: '折叠底部面板',
|
||||
expandBottomPanel: '展开底部面板',
|
||||
terminalError: '终端连接失败',
|
||||
@@ -166,12 +169,6 @@ export const zh = {
|
||||
changesOpenDiffTab: '在独立页签中打开',
|
||||
changesClosePreview: '关闭预览',
|
||||
changesResizePreview: '调整预览高度',
|
||||
changesDiffOpenTitle: '差异展开方式',
|
||||
changesDiffOpenDesc: '「展开为独立页签」把 diff 放到哪里',
|
||||
changesDiffOpenFloat: '浮窗',
|
||||
changesDiffOpenFloatDesc: '作为自由窗口居中打开,可拖拽、缩放、置顶',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停靠在源面板下方(VSCode 式 diff 分栏)',
|
||||
changesLoadError: '会话文件记录暂不可用',
|
||||
staged: '已暂存',
|
||||
unstaged: '未暂存',
|
||||
@@ -246,13 +243,6 @@ export const zh = {
|
||||
settingsIntro: '管理侧边卡片的显示内容与默认行为',
|
||||
settingsPopupDesc: '为「{feature}」配置相关选项',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
|
||||
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
@@ -476,6 +466,12 @@ export const zh = {
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
files: 'Files',
|
||||
guideDescFiles: 'Browse the workspace tree; click a file to preview or edit',
|
||||
guideDescGit: 'Git changes and this session\'s file edits, in one tab',
|
||||
guideDescSubagent: 'Subagent topology and background jobs, live',
|
||||
guideDescSidechat: 'An independent follow-up thread inheriting this session\'s context',
|
||||
guideDescTerminal: 'A real shell (xterm + pty) with reconnect',
|
||||
guideDescBrowser: 'An embedded browser that can take over chat links',
|
||||
changesSessionEmpty: 'No file operations in this session yet',
|
||||
changesRead: 'Read',
|
||||
changesMdReading: 'Reading',
|
||||
@@ -545,7 +541,6 @@ 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',
|
||||
pinTerminal: 'Pin Terminal',
|
||||
@@ -560,8 +555,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Pinned globally',
|
||||
pinnedRailLabel: 'Pinned Terminals',
|
||||
closePinnedTerminal: 'Close Terminal',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
collapseBottomPanel: 'Collapse bottom panel',
|
||||
expandBottomPanel: 'Expand bottom panel',
|
||||
terminalError: 'Terminal connection failed',
|
||||
@@ -618,12 +611,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
changesOpenDiffTab: 'Open in a diff tab',
|
||||
changesClosePreview: 'Close preview',
|
||||
changesResizePreview: 'Resize preview',
|
||||
changesDiffOpenTitle: 'Diff opens as',
|
||||
changesDiffOpenDesc: 'Where the "expand to a diff tab" action lands the diff',
|
||||
changesDiffOpenFloat: 'Free window',
|
||||
changesDiffOpenFloatDesc: 'A floating window centered on the viewport — drag, resize, keep on top',
|
||||
changesDiffOpenPane: 'Pane',
|
||||
changesDiffOpenPaneDesc: 'Docked below the source panel (VSCode-style diff split)',
|
||||
changesLoadError: 'Session file records are unavailable right now',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
@@ -698,13 +685,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsIntro: 'Manage what the side card shows and how it behaves',
|
||||
settingsPopupDesc: 'Configure related options for {feature}',
|
||||
settingsDone: 'Done',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model',
|
||||
settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)',
|
||||
settingsTitleBarTitle: 'Position compatibility mode',
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* The plugin's content, registered into DSH's native right Sidebar.
|
||||
*
|
||||
* Every tab the plugin can draw becomes a native tab TYPE plus a BODY:
|
||||
*
|
||||
* - the `editor` type is both a page (the files window) and a resource
|
||||
* viewer — it claims `dsh-resource://file/**` at the default `extension`
|
||||
* band, which outranks the built-in `text` preview (`fallback`) and the
|
||||
* built-in `files` tree (`builtin`), so every file the product opens lands
|
||||
* in the plugin's editor;
|
||||
* - the built-in `files` page kind is TAKEN OVER by an `extension`
|
||||
* registration of the same kind, so `openTab('files')` draws the plugin's
|
||||
* explorer instead of the built-in tree; the built-in resumes when this
|
||||
* plugin unregisters;
|
||||
* - every other descriptor (changes / subagent / side chat / terminal /
|
||||
* browser / diff) becomes a page type of its own.
|
||||
*
|
||||
* The registrations follow the plugin's registry lifecycle: a descriptor
|
||||
* registered later (an external plugin) gets its native type too, and a type
|
||||
* the user disabled in the side-card settings is unregistered — so it is
|
||||
* absent from the native guide and `openTab` refuses it.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { parseFileAddress } from '../resource-address.ts'
|
||||
import type { BetterSidebarService, TabDescriptor } from '../service.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import {
|
||||
NativeTabBody,
|
||||
NativeTabTitle,
|
||||
type NativeBodyInjected,
|
||||
type NativeTabInfo,
|
||||
type NativeTabParams,
|
||||
type NativeTabRecords,
|
||||
} from './tab-adapter.tsx'
|
||||
|
||||
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
|
||||
interface NativeTabRegistry {
|
||||
register(definition: {
|
||||
id: string
|
||||
kind: string
|
||||
patterns?: readonly string[]
|
||||
priority?: 'extension' | 'builtin' | 'fallback'
|
||||
canOpen?: (address: string) => boolean
|
||||
title: (address: string) => string
|
||||
guide?: readonly { order: number; title: () => string; description?: () => string; icon?: unknown }[]
|
||||
}): () => void
|
||||
}
|
||||
|
||||
/** The kind the plugin's files window owns (page + file viewer). */
|
||||
const EDITOR_KIND = 'editor'
|
||||
|
||||
/** The built-in page kind this plugin takes over. */
|
||||
const FILES_KIND = 'files'
|
||||
|
||||
/** The plugin's implementation id for a descriptor (unique across kinds). */
|
||||
function nativeId(descriptorId: string): string {
|
||||
return `dsh-better-sidebar:${descriptorId}`
|
||||
}
|
||||
|
||||
/** The descriptor's title text, evaluated fresh for the current locale. */
|
||||
function titleOf(descriptor: TabDescriptor): string {
|
||||
return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
||||
}
|
||||
|
||||
/**
|
||||
* The guide fields carrying the descriptor's own description line, evaluated
|
||||
* fresh for the current locale. DSH 0.1.5-rc.1 renders `description` only
|
||||
* while the guide lists at most 4 entries, and a descriptor that declares
|
||||
* none must reach the host with NO `description` field at all — the host has
|
||||
* no fallback of its own, so an empty string would render as a blank second
|
||||
* line rather than a clean title-only capsule.
|
||||
* @param descriptor - the tab descriptor owning the guide entry.
|
||||
* @returns the `description` field, or an empty object.
|
||||
*/
|
||||
function guideDescriptionOf(descriptor: TabDescriptor | undefined): { description?: () => string } {
|
||||
const description = descriptor?.description
|
||||
return description === undefined ? {} : { description: () => (typeof description === 'function' ? description() : description) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The guide row's glyph for a descriptor icon (nothing when it has none).
|
||||
* The native guide renders `entry.icon`, so a takeover registered without one
|
||||
* is the only row in the list with a blank leading slot.
|
||||
* @param icon - the descriptor's icon value.
|
||||
* @returns the guide-entry icon fields, or an empty object.
|
||||
*/
|
||||
function guideIconOf(icon: TabDescriptor['icon']): { icon?: (props: { size?: number }) => unknown } {
|
||||
return typeof icon === 'function' ? { icon: (props: { size?: number }) => icon(props.size ?? 16) } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip title of a file address: its own file name. A resource tab is
|
||||
* identified by its address, so the strip must name the FILE — the
|
||||
* descriptor's title ("Files") would make every open file look identical.
|
||||
* @param address - the native tab's content address.
|
||||
* @returns the file name, or undefined when the address is not a file.
|
||||
*/
|
||||
function fileTitleOf(address: string): string | undefined {
|
||||
const parsed = parseFileAddress(address)
|
||||
if (parsed === undefined) return undefined
|
||||
const segments = parsed.path.split('/').filter(segment => segment !== '')
|
||||
return segments.length === 0 ? undefined : segments[segments.length - 1]
|
||||
}
|
||||
|
||||
/** One descriptor's live native registrations. */
|
||||
interface Registration {
|
||||
readonly dispose: () => void
|
||||
}
|
||||
|
||||
/** Everything the registrations need. */
|
||||
export interface NativeSurfaceDeps {
|
||||
readonly ctx: Context
|
||||
readonly store: SidebarStore
|
||||
readonly service: BetterSidebarService
|
||||
/** The shared native tab record registry (the surface writes it too). */
|
||||
readonly records: NativeTabRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the plugin's tabs into the native right Sidebar and keep them in
|
||||
* step with the plugin's own registry and settings.
|
||||
* @param deps - client context, the plugin store/service, and the records.
|
||||
* @returns a disposer unregistering everything.
|
||||
*/
|
||||
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
const { ctx, store, service, records } = deps
|
||||
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
|
||||
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
|
||||
// `sidebarRightTabs`, so a declaration-triggered registration reads the
|
||||
// service as missing and — because the declaration never collapses —
|
||||
// registers nothing, permanently. Observed on a real profile: the
|
||||
// declaration callback fired with both `sidebarRight` and
|
||||
// `sidebarRightTabs` undefined, and the registry appeared a moment later.
|
||||
// `ctx.inject` re-runs this body whenever the service appears/reappears,
|
||||
// which is the lifecycle the registrations need.
|
||||
const seat = ctx.inject(['sidebarRightTabs'], (injected) => {
|
||||
const tabs = injected.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
|
||||
if (tabs === undefined) return
|
||||
const live = new Map<string, Registration>()
|
||||
|
||||
const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => {
|
||||
const address = parseFileAddress(info.tab.contentId)
|
||||
return address === undefined ? undefined : { path: address.path }
|
||||
}
|
||||
const fileSessionIdOf = (info: NativeTabInfo): string | undefined => {
|
||||
const address = parseFileAddress(info.tab.contentId)
|
||||
return address !== undefined && address.scope === 'session' ? address.sessionId : undefined
|
||||
}
|
||||
|
||||
/** Register the body + chip-title slots for one native implementation id. */
|
||||
const registerSlots = (
|
||||
id: string,
|
||||
injected: Omit<NativeBodyInjected, 'sessionId'>,
|
||||
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
|
||||
): Array<() => void> => [
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: id,
|
||||
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
|
||||
}, NativeTabBody)),
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: id,
|
||||
inject: () => ({ records }),
|
||||
}, NativeTabTitle)),
|
||||
]
|
||||
|
||||
/** One descriptor's native type + body + title, as one disposable. */
|
||||
const registerDescriptor = (descriptor: TabDescriptor): (() => void) => {
|
||||
const id = nativeId(descriptor.id)
|
||||
const isEditor = descriptor.id === EDITOR_KIND
|
||||
const icon = descriptor.icon
|
||||
const disposeType = tabs.register({
|
||||
id,
|
||||
kind: descriptor.id,
|
||||
...(isEditor
|
||||
? {
|
||||
patterns: ['dsh-resource://file/**'],
|
||||
canOpen: (address: string) => parseFileAddress(address) !== undefined,
|
||||
}
|
||||
: {}),
|
||||
// An external implementation outranks the product's own viewers, which
|
||||
// is what lets the plugin's editor take over file addresses.
|
||||
priority: 'extension',
|
||||
// A resource tab is titled by the file it shows; a page tab keeps the
|
||||
// descriptor's own title.
|
||||
title: (address: string) => (isEditor ? fileTitleOf(address) ?? titleOf(descriptor) : titleOf(descriptor)),
|
||||
// No guide entry for the editor: its page identity is the `files`
|
||||
// kind takeover below (same view, same title), so listing both would
|
||||
// offer the reader two identical "Files" rows. The type itself stays
|
||||
// registered as the file RESOURCE viewer.
|
||||
...(descriptor.hidden === true || isEditor
|
||||
? {}
|
||||
: {
|
||||
guide: [{
|
||||
order: descriptor.order ?? 100,
|
||||
title: () => titleOf(descriptor),
|
||||
...guideDescriptionOf(descriptor),
|
||||
...guideIconOf(icon),
|
||||
}],
|
||||
}),
|
||||
})
|
||||
const slots = registerSlots(
|
||||
id,
|
||||
{ ctx, store, service, records, descriptorId: descriptor.id },
|
||||
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
|
||||
)
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
}
|
||||
}
|
||||
|
||||
/** One `files`-kind takeover: the plugin's explorer under the built-in kind. */
|
||||
const registerFilesKind = (editor: TabDescriptor | undefined): (() => void) => {
|
||||
const id = 'dsh-better-sidebar:files'
|
||||
const disposeType = tabs.register({
|
||||
id,
|
||||
kind: FILES_KIND,
|
||||
priority: 'extension',
|
||||
title: () => t('files'),
|
||||
guide: [{
|
||||
order: 10,
|
||||
title: () => t('files'),
|
||||
// The takeover IS the editor descriptor's page, so it carries the
|
||||
// editor's glyph AND guide line: without them the "Files" row is
|
||||
// the only guide entry with a blank icon slot and no description.
|
||||
...guideDescriptionOf(editor),
|
||||
...guideIconOf(editor?.icon),
|
||||
}],
|
||||
})
|
||||
const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bring the live registrations in line with the registry + the settings. */
|
||||
const sync = (): void => {
|
||||
const wanted = new Map<string, () => () => void>()
|
||||
for (const descriptor of service.getTabs()) {
|
||||
if (!service.isTabEnabled(descriptor.id)) continue
|
||||
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
|
||||
}
|
||||
for (const [descriptorId, registration] of live) {
|
||||
if (wanted.has(descriptorId)) continue
|
||||
registration.dispose()
|
||||
live.delete(descriptorId)
|
||||
}
|
||||
for (const [descriptorId, create] of wanted) {
|
||||
if (live.has(descriptorId)) continue
|
||||
live.set(descriptorId, { dispose: create() })
|
||||
}
|
||||
// The built-in files kind follows the editor type's switch: with the
|
||||
// editor disabled the plugin has no explorer to put there.
|
||||
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
|
||||
const hasFiles = live.has(FILES_KIND)
|
||||
if (wantsFiles && !hasFiles) {
|
||||
live.set(FILES_KIND, { dispose: registerFilesKind(service.getTab(EDITOR_KIND)) })
|
||||
}
|
||||
if (!wantsFiles && hasFiles) {
|
||||
live.get(FILES_KIND)?.dispose()
|
||||
live.delete(FILES_KIND)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)]
|
||||
sync()
|
||||
return () => {
|
||||
for (const registration of live.values()) registration.dispose()
|
||||
live.clear()
|
||||
for (const dispose of disposeSubscriptions.reverse()) dispose()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
void seat.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export type { NativeTabRecords }
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The plugin's write face over DSH's native right Sidebar (`ctx.sidebarRight`).
|
||||
*
|
||||
* The service speaks in the plugin's own vocabulary (tab type, seed, session
|
||||
* scope); this module turns those into the native surface's vocabulary
|
||||
* (kind + navigation params, or a `dsh-resource://` address) and forwards
|
||||
* tab-record operations to the plugin's native record registry.
|
||||
*
|
||||
* Two native limits shape the implementation:
|
||||
*
|
||||
* - the surface exists only while a session's panel is mounted, and the
|
||||
* service's public face (`ISidebarRight`) writes only into THAT session.
|
||||
* The controller also carries `openTabIn` / `openResourceIn` /
|
||||
* `closeIn`, which act on any session whose store the runtime has minted;
|
||||
* both are probed at call time, and an open for a session that has no
|
||||
* store yet is QUEUED and replayed when that session comes on screen;
|
||||
* - layout state is memory-only, so a queued open is not durable either.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { fileAddressFor } from '../resource-address.ts'
|
||||
import type { NativeTabParams, SidebarSurface } from '../service.ts'
|
||||
import type { NativeTabRecords } from './tab-adapter.tsx'
|
||||
|
||||
/** One open the surface could not place yet. */
|
||||
type Pending =
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
|
||||
|
||||
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
|
||||
interface NativeController {
|
||||
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
close(tabId: string): void
|
||||
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
|
||||
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
closeIn?(sessionId: string, tabId: string): void
|
||||
}
|
||||
|
||||
/** The plugin's write face over the native surface. */
|
||||
export interface NativeSurface extends SidebarSurface {
|
||||
/** Replay opens that were queued for a session that had no mounted surface. */
|
||||
flushPending(): void
|
||||
/** Stop observing the session list. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** The active session id, as the client list reports it. */
|
||||
function activeSessionId(ctx: Context): string | undefined {
|
||||
try {
|
||||
return ctx.sessions.list.getSnapshot().current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the plugin's write face to the native controller.
|
||||
* @param ctx - the client context (session list + `ctx.sidebarRight`).
|
||||
* @param records - the plugin's native tab record registry.
|
||||
* @returns the surface, plus a disposer unbinding its session subscription.
|
||||
*/
|
||||
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
|
||||
const pending: Pending[] = []
|
||||
const controller = (): NativeController | undefined =>
|
||||
ctx.get('sidebarRight') as unknown as NativeController | undefined
|
||||
|
||||
const place = (entry: Pending): boolean => {
|
||||
const api = controller()
|
||||
if (api === undefined) return false
|
||||
const active = activeSessionId(ctx)
|
||||
const onScreen = active !== undefined && active === entry.sessionId
|
||||
if (entry.kind === 'tab') {
|
||||
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
|
||||
if (onScreen) {
|
||||
api.openTab(entry.tabKind, options)
|
||||
return true
|
||||
}
|
||||
if (api.openTabIn !== undefined) {
|
||||
api.openTabIn(entry.sessionId, entry.tabKind, options)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const options = {
|
||||
...(entry.line === undefined ? {} : { params: { line: entry.line } }),
|
||||
revealIfOpened: entry.revealIfOpened,
|
||||
}
|
||||
if (onScreen) {
|
||||
api.openResource(entry.address, options)
|
||||
return true
|
||||
}
|
||||
if (api.openResourceIn !== undefined) {
|
||||
api.openResourceIn(entry.sessionId, entry.address, options)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const flushPending = (): void => {
|
||||
if (pending.length === 0) return
|
||||
for (let index = pending.length - 1; index >= 0; index--) {
|
||||
const entry = pending[index]
|
||||
if (entry !== undefined && place(entry)) pending.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const enqueue = (entry: Pending): void => {
|
||||
if (!place(entry)) pending.push(entry)
|
||||
}
|
||||
|
||||
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
|
||||
return {
|
||||
openTab({ sessionId, kind, params, revealIfOpened }) {
|
||||
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
|
||||
},
|
||||
openResource({ sessionId, address, line, revealIfOpened }) {
|
||||
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened })
|
||||
},
|
||||
fileAddress(sessionId, cwd, path) {
|
||||
return fileAddressFor(sessionId, cwd, path)
|
||||
},
|
||||
close(sessionId, tabId) {
|
||||
const record = records.get(tabId)
|
||||
if (record === undefined) return undefined
|
||||
records.drop(tabId)
|
||||
const api = controller()
|
||||
if (api !== undefined) {
|
||||
if (sessionId === activeSessionId(ctx)) api.close(tabId)
|
||||
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
|
||||
}
|
||||
return { type: record.tab.type, title: record.tab.title }
|
||||
},
|
||||
update(tabId, patch) {
|
||||
if (!records.has(tabId)) return false
|
||||
records.update(tabId, patch)
|
||||
return true
|
||||
},
|
||||
activate(tabId) {
|
||||
// The native surface has no cross-pane activation face the plugin needs:
|
||||
// a tab is focused by opening its (kind, address) again, which the
|
||||
// native open already de-duplicates.
|
||||
return records.has(tabId)
|
||||
},
|
||||
has: tabId => records.has(tabId),
|
||||
flushPending,
|
||||
dispose: () => { unsubscribe() },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The plugin's side of a native right-Sidebar tab.
|
||||
*
|
||||
* A plugin tab descriptor is one registration in the plugin's own registry
|
||||
* (`ctx.betterSidebar`); this module adapts the native tab record to that
|
||||
* descriptor's component contract. The native surface carries the tab's
|
||||
* record (id, kind, title, content identity, navigation params, lifetime
|
||||
* signal); the plugin's components expect a `SidebarTab` plus the explorer
|
||||
* state the plugin's own layout used to hold — both live here.
|
||||
*
|
||||
* The adapter owns three things the native layout does not carry:
|
||||
*
|
||||
* - a synthetic plugin `SidebarTab` per native tab id, minted from the native
|
||||
* record + `navigation.params` and kept live across navigations, so a
|
||||
* component that retitles itself (`updateTab`) or rewrites its path (the
|
||||
* editor's in-place file switch) keeps working;
|
||||
* - the explorer's per-tab expansion/reveal sets;
|
||||
* - the per-kind instance counter behind titles like "Terminal 2".
|
||||
*
|
||||
* Nothing here is a singleton: the registry is created once per client
|
||||
* activation and handed to every registration.
|
||||
*/
|
||||
import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import { RenderBoundary } from '../RenderBoundary.tsx'
|
||||
import { OrphanedTab } from '../OrphanedTab.tsx'
|
||||
import { referenceInChat } from '../reference-in-chat.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import type { SidebarStore, SidebarTab, TabType } from '../state.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* The plugin-side seed a native open carries in `navigation.params`.
|
||||
* JSON-shaped by convention (the native surface does not validate it).
|
||||
*/
|
||||
export interface NativeTabParams {
|
||||
/** Overrides the descriptor's title for this instance. */
|
||||
readonly title?: string
|
||||
/** A file path (the editor window's content seed). */
|
||||
readonly path?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
readonly url?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
readonly diff?: SidebarTab['diff']
|
||||
/** JSON-serializable custom state carried on the synthetic record. */
|
||||
readonly meta?: unknown
|
||||
/** A line to land on (file addresses carry it as a navigation parameter). */
|
||||
readonly line?: number
|
||||
}
|
||||
|
||||
/** The native tab information this adapter reads (structural mirror of `useTabInfo`). */
|
||||
export interface NativeTabInfo {
|
||||
readonly tab: {
|
||||
readonly id: string
|
||||
readonly kind: string
|
||||
readonly title: string
|
||||
readonly contentId: string
|
||||
readonly visible: boolean
|
||||
readonly navigation: {
|
||||
readonly address: string
|
||||
readonly params: NativeTabParams | undefined
|
||||
readonly revision: number
|
||||
}
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
}
|
||||
|
||||
/** One native tab's plugin-side view state. */
|
||||
interface View {
|
||||
tab: SidebarTab
|
||||
scope: SessionScope
|
||||
expanded: string[]
|
||||
revealed: string[]
|
||||
/** Bumped on every mutation; the components subscribe to it. */
|
||||
version: number
|
||||
}
|
||||
|
||||
/** The plugin-side record registry for native tabs. */
|
||||
export interface NativeTabRecords {
|
||||
/**
|
||||
* The synthetic record for a native tab, minted on first sight and kept
|
||||
* across navigations (a navigation refreshes the seed fields, never the
|
||||
* identity or a plugin-side title/meta mutation).
|
||||
* @param input - the native record and the session it lives in.
|
||||
* @returns the current view state.
|
||||
*/
|
||||
ensure(input: {
|
||||
id: string
|
||||
kind: string
|
||||
title: string
|
||||
params: NativeTabParams | undefined
|
||||
scope: SessionScope
|
||||
/**
|
||||
* The descriptor's own factory, called ONCE for a record that arrives
|
||||
* without seed fields (a native guide open, which knows nothing about the
|
||||
* plugin's per-instance minting): it supplies the title and the meta a
|
||||
* view needs — the side chat's thread bootstrap, the terminal's name.
|
||||
*/
|
||||
mint?: () => { title?: string; meta?: unknown } | undefined
|
||||
}): View
|
||||
/** One record by native tab id. */
|
||||
get(id: string): View | undefined
|
||||
/** Whether this id belongs to a native tab (vs the plugin's own layout). */
|
||||
has(id: string): boolean
|
||||
/** Merge a patch into the synthetic record (the `updateTab` path). */
|
||||
update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** Forget a record (the native tab closed). */
|
||||
drop(id: string): void
|
||||
/** Toggle one directory in a record's expansion set. */
|
||||
toggleExpanded(id: string, path: string): void
|
||||
/** Mint the next instance number of a kind (titles like "Terminal 2"). */
|
||||
nextInstance(kind: string): number
|
||||
/** A per-record version for `useSyncExternalStore`. */
|
||||
versionOf(id: string): number
|
||||
/** Subscribe to record changes (title/path/meta/expanded). */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Create the record registry for one client activation. */
|
||||
export function createNativeTabRecords(): NativeTabRecords {
|
||||
const views = new Map<string, View>()
|
||||
const instances = new Map<string, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
const notify = (): void => { for (const listener of listeners) listener() }
|
||||
const put = (id: string, view: View): void => {
|
||||
views.set(id, { ...view, version: view.version + 1 })
|
||||
notify()
|
||||
}
|
||||
return {
|
||||
ensure({ id, kind, title, params, scope, mint }) {
|
||||
const existing = views.get(id)
|
||||
if (existing === undefined) {
|
||||
const seeded = params?.title === undefined && params?.meta === undefined ? mint?.() : undefined
|
||||
const meta = params?.meta ?? seeded?.meta
|
||||
const minted: View = {
|
||||
tab: {
|
||||
id,
|
||||
type: kind as TabType,
|
||||
title: params?.title ?? seeded?.title ?? title,
|
||||
...(params?.path === undefined ? {} : { path: params.path }),
|
||||
...(params?.diff === undefined ? {} : { diff: params.diff }),
|
||||
...(meta === undefined ? {} : { meta }),
|
||||
},
|
||||
scope,
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
version: 0,
|
||||
}
|
||||
views.set(id, minted)
|
||||
return minted
|
||||
}
|
||||
// A navigation may carry new seed fields (the editor's in-place switch,
|
||||
// a browser tab pointed at another URL); the record's identity and any
|
||||
// plugin-side mutation (title/meta from updateTab) stay.
|
||||
const patch: Partial<SidebarTab> = {}
|
||||
if (params?.path !== undefined && params.path !== existing.tab.path) patch.path = params.path
|
||||
if (params?.diff !== undefined) patch.diff = params.diff
|
||||
if (params?.url !== undefined) {
|
||||
const meta = typeof existing.tab.meta === 'object' && existing.tab.meta !== null
|
||||
? existing.tab.meta as Record<string, unknown>
|
||||
: {}
|
||||
patch.meta = { ...meta, url: params.url }
|
||||
}
|
||||
if (existing.scope.cwd !== scope.cwd) {
|
||||
views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } })
|
||||
return views.get(id)!
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return existing
|
||||
const next: View = { ...existing, tab: { ...existing.tab, ...patch } }
|
||||
views.set(id, next)
|
||||
return next
|
||||
},
|
||||
get: id => views.get(id),
|
||||
has: id => views.has(id),
|
||||
update(id, patch) {
|
||||
const entry = views.get(id)
|
||||
if (entry === undefined) return
|
||||
put(id, { ...entry, tab: { ...entry.tab, ...patch } })
|
||||
},
|
||||
drop(id) {
|
||||
if (views.delete(id)) notify()
|
||||
},
|
||||
toggleExpanded(id, path) {
|
||||
const entry = views.get(id)
|
||||
if (entry === undefined) return
|
||||
const expanded = entry.expanded.includes(path)
|
||||
? entry.expanded.filter(candidate => candidate !== path)
|
||||
: [...entry.expanded, path]
|
||||
put(id, { ...entry, expanded })
|
||||
},
|
||||
nextInstance(kind) {
|
||||
const next = (instances.get(kind) ?? 0) + 1
|
||||
instances.set(kind, next)
|
||||
return next
|
||||
},
|
||||
versionOf: id => views.get(id)?.version ?? 0,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** What a body registration injects (the plugin's business face). */
|
||||
export interface NativeBodyInjected {
|
||||
readonly sessionId: string
|
||||
readonly ctx: Context
|
||||
readonly store: SidebarStore
|
||||
readonly service: BetterSidebarService
|
||||
readonly records: NativeTabRecords
|
||||
/** The descriptor id this body draws (one registration per descriptor). */
|
||||
readonly descriptorId: string
|
||||
/**
|
||||
* Extra seed fields derived from the native record — a file address carries
|
||||
* its path there, not in `navigation.params`.
|
||||
*/
|
||||
readonly paramsOf?: (info: NativeTabInfo) => NativeTabParams | undefined
|
||||
/**
|
||||
* The session the body acts in, when the record names one (a
|
||||
* `session`-scoped file address names its own session); absent falls back to
|
||||
* the session the slot is scoped to.
|
||||
*/
|
||||
readonly sessionIdOf?: (info: NativeTabInfo) => string | undefined
|
||||
}
|
||||
|
||||
/** The props the slot framework adds to every tab body and title. */
|
||||
export interface NativeBodyFrameworkProps {
|
||||
readonly useTabInfo: () => NativeTabInfo
|
||||
}
|
||||
|
||||
/** Subscribe a component to its own record's mutations. */
|
||||
function useRecordVersion(records: NativeTabRecords, id: string): number {
|
||||
return useSyncExternalStore(
|
||||
listener => records.subscribe(listener),
|
||||
() => records.versionOf(id),
|
||||
)
|
||||
}
|
||||
|
||||
/** The current session's workspace root, live from the client session list. */
|
||||
function useSessionCwd(ctx: Context, sessionId: string): string | undefined {
|
||||
return useSyncExternalStore(
|
||||
useMemo(() => (listener: () => void) => ctx.sessions.list.subscribe(listener), [ctx]),
|
||||
() => ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin tab rendered inside the native right Sidebar: the descriptor's
|
||||
* own component with the plugin's props, over a synthetic record minted from
|
||||
* the native tab and dropped when the record ends.
|
||||
*/
|
||||
export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkProps): ReactNode {
|
||||
const { ctx, store, service, records, descriptorId, useTabInfo } = props
|
||||
const info = useTabInfo()
|
||||
const nativeTab = info.tab
|
||||
const version = useRecordVersion(records, nativeTab.id)
|
||||
const sessionId = props.sessionIdOf?.(info) ?? props.sessionId
|
||||
const cwd = useSessionCwd(ctx, sessionId)
|
||||
const scope = useMemo((): SessionScope => ({ sessionId, cwd }), [sessionId, cwd])
|
||||
// `version` is not read: it only forces this render when the record changed.
|
||||
void version
|
||||
const derived = props.paramsOf?.(info)
|
||||
const params = derived === undefined && nativeTab.navigation.params === undefined
|
||||
? undefined
|
||||
: { ...derived, ...nativeTab.navigation.params }
|
||||
const descriptor = service.getTab(descriptorId)
|
||||
const view = records.ensure({
|
||||
id: nativeTab.id,
|
||||
kind: nativeTab.kind,
|
||||
title: nativeTab.title,
|
||||
params,
|
||||
scope,
|
||||
mint: () => {
|
||||
const state = store.getSnapshot().state
|
||||
if (descriptor?.createTab === undefined || state === undefined) return undefined
|
||||
const minted = descriptor.createTab(state)
|
||||
return minted === null ? undefined : { title: minted.tab.title, meta: minted.tab.meta }
|
||||
},
|
||||
})
|
||||
useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id])
|
||||
if (descriptor === undefined) {
|
||||
// The orphaned fallback sits in the SAME native host as a live body, so
|
||||
// it gets the same full-height box (its own root also relies on the
|
||||
// `flex: 1` contract the column host restores). The host div carries
|
||||
// `data-dsh-native-tab-host` (empty value) so the e2e lane can assert
|
||||
// the fill, mirroring `data-dsh-better-sidebar`.
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: css.nativeTabHost, 'data-dsh-native-tab-host': '' },
|
||||
createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible }),
|
||||
)
|
||||
}
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
// The full-height host wrapper (see the `.nativeTabHost` rule in
|
||||
// sidebar.module.css for the native `.paneBody` contract); its
|
||||
// `data-dsh-native-tab-host` attribute lets the e2e lane assert the fill.
|
||||
createElement(
|
||||
'div',
|
||||
{ className: css.nativeTabHost, 'data-dsh-native-tab-host': '' },
|
||||
createElement(descriptor.component, {
|
||||
ctx,
|
||||
store,
|
||||
scope,
|
||||
tab: view.tab,
|
||||
visible: nativeTab.visible,
|
||||
expanded: view.expanded,
|
||||
revealed: view.revealed,
|
||||
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
|
||||
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
|
||||
onOpenDiff: (tab: SidebarTab) => {
|
||||
service.openTab({
|
||||
type: 'diff',
|
||||
title: tab.title,
|
||||
id: tab.id,
|
||||
...(tab.diff === undefined ? {} : { diff: tab.diff }),
|
||||
}, scope)
|
||||
},
|
||||
onSubagentJump: (childSessionId: string) => {
|
||||
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** What a title registration injects. */
|
||||
export interface NativeTitleInjected {
|
||||
readonly records: NativeTabRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* A live tab chip: the synthetic record's title (the editor rewrites it on an
|
||||
* in-place file switch, the side chat on the thread's first prompt). Without
|
||||
* this registration the chip would keep the title captured when the tab
|
||||
* opened.
|
||||
*/
|
||||
export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkProps): ReactNode {
|
||||
const { records, useTabInfo } = props
|
||||
const nativeTab = useTabInfo().tab
|
||||
return useSyncExternalStore(
|
||||
listener => records.subscribe(listener),
|
||||
() => records.get(nativeTab.id)?.tab.title ?? nativeTab.title,
|
||||
)
|
||||
}
|
||||
|
||||
/** The component pair one descriptor contributes to the native surface. */
|
||||
export interface NativeTabComponents {
|
||||
readonly body: ComponentType<NativeBodyInjected & NativeBodyFrameworkProps>
|
||||
readonly title: ComponentType<NativeTitleInjected & NativeBodyFrameworkProps>
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Interception of the chat's file-open funnel (DSH 0.1.2-alpha hosts).
|
||||
*
|
||||
* The alpha funnel is the Typert remote namespace `remote.session`: every
|
||||
* chat-side file open (tool-row path links, the produced-files row, prose
|
||||
* file mentions, inline-code paths) funnels through ChatView's injected
|
||||
* `openFile`, which calls `ctx.remote.session.openWorkspacePath({ path })`
|
||||
* with the path ALREADY resolved against the session cwd (verified against
|
||||
* the DSH source: `packages/client/ui-chat/src/client/apply.ts` is the only
|
||||
* production caller). The host side (`api/session-controller`) hands the
|
||||
* path to the OS's default application (`openNativePath` → xdg-open on
|
||||
* Linux). The pre-alpha funnel `ctx.workspaces.openPath` is gone — the
|
||||
* alpha `IWorkspaces` has no openPath at all.
|
||||
*
|
||||
* `remote.session` is a cordis service (the gateway client's
|
||||
* RemoteNamespaceService, key `remote.session`) whose methods are installed
|
||||
* as ACCESSOR properties (`Object.defineProperty(service, method, {
|
||||
* configurable: true, get })`) — the getter reads the method table at
|
||||
* ACCESS time and returns the invocation closure. A plain assignment would
|
||||
* not stick (no setter), so the wrapper shadows the method with a data
|
||||
* property and restores the captured accessor descriptor on dispose.
|
||||
*
|
||||
* The namespace service appears asynchronously (the session-controller
|
||||
* contribution mounts after the connection is up) and is disposed/recreated
|
||||
* on contribution remounts; the caller therefore enters through
|
||||
* `ctx.inject(['remote.session'], …)`, which re-fires on every remount.
|
||||
*
|
||||
* The wrapper is dependency-free by design (no React / ui-primitives), so
|
||||
* the takeover logic is unit-testable and the file stays importable from
|
||||
* the test runtime.
|
||||
*/
|
||||
|
||||
/** The one request shape the funnel method takes (mirror of the host's
|
||||
* SessionOpenWorkspacePathRequest). */
|
||||
export interface OpenWorkspacePathRequest {
|
||||
/** Absolute path (the chat caller already resolved it against the cwd). */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the funnel method resolves to (mirror of the typert `RemoteResult`
|
||||
* envelope): EVERY remote method folds its business value into
|
||||
* `{ ok: true, value }` / `{ ok: false, error }` — the chat caller branches
|
||||
* on `result.ok`, so a bare business value would be misread as a failure
|
||||
* (`result.error.message` reads `message` off undefined).
|
||||
*/
|
||||
export type OpenWorkspacePathResult =
|
||||
| { readonly ok: true; readonly value: { opened: boolean } }
|
||||
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
|
||||
|
||||
/** The namespace slice the wrapper shadows (mirror of the gateway client's
|
||||
* RemoteNamespaceService for the `session` namespace). */
|
||||
export interface OpenWorkspacePathService {
|
||||
openWorkspacePath(request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult>
|
||||
}
|
||||
|
||||
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
|
||||
export interface OpenPathInterceptDeps {
|
||||
/**
|
||||
* Whether to take over this call: the `interceptOpenPath` pref AND the
|
||||
* editor tab's own enable switch must both be on (an editor that cannot
|
||||
* open must not swallow opens — they fall through to the Host).
|
||||
*/
|
||||
takeoverEnabled(): boolean
|
||||
/** The session whose scope the sidebar editor loads the file in (current session). */
|
||||
currentSessionId(): string | undefined
|
||||
/** Route the open into the sidebar editor (the established openSidebarFile). */
|
||||
openInSidebar(path: string, sessionId: string): void
|
||||
/** Route a folder-reveal gesture ("Show in folder" passes '.') into the sidebar explorer. */
|
||||
revealInExplorer(path: string, sessionId: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a path is the "Show in folder" folder-reveal gesture. The stock
|
||||
* ui-deliverables row passes `'.'` (the session workspace root, resolved by
|
||||
* the chat view to `"<cwd>/."`); any path whose final segment is `.` is the
|
||||
* same gesture. A directory has no editor content, so these opens must reach
|
||||
* the explorer instead of an editor tab.
|
||||
*/
|
||||
export function isFolderRevealPath(path: string): boolean {
|
||||
if (path === '.' || path === './') return true
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
return trimmed === '.' || /[\\/]\.$/.test(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadow `remote.session.openWorkspacePath`: intercepted calls open the file
|
||||
* in the sidebar editor and resolve with the remote SUCCESS ENVELOPE
|
||||
* (`{ ok: true, value: { opened: true } }`, so ChatView shows no error
|
||||
* dialog); anything that declines falls through to the captured original
|
||||
* closure (the host OS's default application) untouched.
|
||||
* The one exception is the folder-reveal gesture, which is routed to
|
||||
* {@link OpenPathInterceptDeps.revealInExplorer} instead.
|
||||
*
|
||||
* The original closure is captured by ACCESSING the accessor once at wrap
|
||||
* time — it invokes whatever method records are mounted at that moment. If
|
||||
* the contribution remounts its methods while the shadow is installed, the
|
||||
* captured closure points at the old records; the session namespace is
|
||||
* effectively permanent in practice, so this is accepted and the shadow is
|
||||
* re-applied anyway when the remount recreates the service (the caller's
|
||||
* `ctx.inject` re-fires).
|
||||
*
|
||||
* @param service - the `remote.session` namespace service.
|
||||
* @param deps - per-call takeover decisions.
|
||||
* @returns the disposer restoring the original accessor descriptor (HMR-safe).
|
||||
*/
|
||||
export function wrapOpenWorkspacePath(
|
||||
service: OpenWorkspacePathService,
|
||||
deps: OpenPathInterceptDeps,
|
||||
): () => void {
|
||||
const KEY = 'openWorkspacePath'
|
||||
const target = service as object
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, KEY)
|
||||
// Invoke the accessor once to capture the CURRENT invocation closure.
|
||||
const original = service.openWorkspacePath
|
||||
// The gateway installs the namespace's whole method group in the same
|
||||
// synchronous window as the service registration, so an inject-observed
|
||||
// service always has the method — but a hand-rolled composition might not;
|
||||
// wrapping nothing would turn fall-throughs into crashes, so decline.
|
||||
if (typeof original !== 'function') return () => {}
|
||||
const wrapped = (request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult> => {
|
||||
if (deps.takeoverEnabled()) {
|
||||
const sessionId = deps.currentSessionId()
|
||||
if (sessionId !== undefined) {
|
||||
if (isFolderRevealPath(request.path)) deps.revealInExplorer(request.path, sessionId)
|
||||
else deps.openInSidebar(request.path, sessionId)
|
||||
return Promise.resolve({ ok: true, value: { opened: true } })
|
||||
}
|
||||
}
|
||||
return original.call(service, request, signal)
|
||||
}
|
||||
Object.defineProperty(target, KEY, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: wrapped,
|
||||
})
|
||||
return () => {
|
||||
// Restore the exact original property (the gateway's accessor) so a
|
||||
// chain of wrappers keeps working across disposals in any order.
|
||||
if (descriptor !== undefined) Object.defineProperty(target, KEY, descriptor)
|
||||
else Reflect.deleteProperty(target, KEY)
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -143,9 +143,8 @@ export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean
|
||||
* Collect every pinned terminal visible to the viewer across ALL cached
|
||||
* session states. Excludes the viewer's own session (those tabs are on its
|
||||
* own strip). Order is stable: sessions in the cache's insertion order,
|
||||
* tabs in tree order (splits → bottomSplits → floats) within each session
|
||||
* — the order tabs were opened/pinned, so the rail never reorders between
|
||||
* renders.
|
||||
* tabs in tree order within each session — the order tabs were
|
||||
* opened/pinned, so the rail never reorders between renders.
|
||||
*/
|
||||
export function collectPinnedTabs(
|
||||
bySession: ReadonlyMap<string, SidebarState>,
|
||||
@@ -154,20 +153,14 @@ export function collectPinnedTabs(
|
||||
const entries: PinnedTabEntry[] = []
|
||||
for (const [homeSessionId, state] of bySession) {
|
||||
if (homeSessionId === viewer.sessionId) continue
|
||||
collectFromTree(state.splits, homeSessionId, viewer, entries)
|
||||
collectFromTree(state.bottomSplits, homeSessionId, viewer, entries)
|
||||
for (const float of state.floats) {
|
||||
if (float.tab.type === 'terminal' && pinnedVisibleTo(float.tab, viewer)) {
|
||||
entries.push({ tab: float.tab, homeSessionId })
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Walk one split tree depth-first, collecting visible pinned terminals. */
|
||||
function collectFromTree(
|
||||
node: SidebarState['splits'],
|
||||
node: SplitNode,
|
||||
homeSessionId: string,
|
||||
viewer: PinnedViewer,
|
||||
out: PinnedTabEntry[],
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { api } from './api.ts'
|
||||
import {
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
@@ -26,7 +25,6 @@ export {
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
}
|
||||
export type { SidebarPrefs, TitleBarScheme }
|
||||
|
||||
@@ -43,12 +41,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
openByDefault: typeof record.openByDefault === 'boolean'
|
||||
? record.openByDefault
|
||||
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
|
||||
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
|
||||
? clampWidthPercent(record.defaultWidthPercent)
|
||||
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
@@ -76,15 +68,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
|
||||
? clampTerminalFontSize(record.terminalFontSize)
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
editorExplorer: typeof record.editorExplorer === 'boolean'
|
||||
? record.editorExplorer
|
||||
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
|
||||
changesDiffFloat: typeof record.changesDiffFloat === 'boolean'
|
||||
? record.changesDiffFloat
|
||||
: SIDEBAR_PREFS_DEFAULTS.changesDiffFloat,
|
||||
workspaceFence: typeof record.workspaceFence === 'boolean'
|
||||
? record.workspaceFence
|
||||
: SIDEBAR_PREFS_DEFAULTS.workspaceFence,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The explorer's `@`-reference button: insert one file or folder reference
|
||||
* into the conversation draft of a session.
|
||||
*
|
||||
* Directories append the folder mention (`@dir/`) as plain text so DSH's
|
||||
* folder decoration and completion keep working; files insert a structured
|
||||
* chip like the native `@` picker, so the whole reference stays one link
|
||||
* instead of decorating only the leading folder. The session-scope context and
|
||||
* the conversation input service are resolved at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*
|
||||
* Shared by the plugin's own panel and the native right-Sidebar tab body, so
|
||||
* both surfaces behave identically.
|
||||
*/
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
|
||||
/**
|
||||
* Reference one path in the session's conversation draft.
|
||||
* @param ctx - the client context.
|
||||
* @param sessionId - the session whose draft receives the reference.
|
||||
* @param cwd - that session's workspace root (for the relative spelling).
|
||||
* @param path - the absolute or workspace-relative path to reference.
|
||||
* @param isDir - whether the path is a directory (folder mention vs file chip).
|
||||
*/
|
||||
export function referenceInChat(
|
||||
ctx: Context,
|
||||
sessionId: string,
|
||||
cwd: string | undefined,
|
||||
path: string,
|
||||
isDir: boolean,
|
||||
): void {
|
||||
const rel = relativeTo(cwd ?? '', path)
|
||||
if (isDir) {
|
||||
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
|
||||
return
|
||||
}
|
||||
if (!insertFileReference(ctx, sessionId, rel)) {
|
||||
appendToDraft(ctx, sessionId, `@${rel}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The DSH resource-address grammar for files (`dsh-resource://file/…`).
|
||||
*
|
||||
* DSH's right Sidebar opens content by ADDRESS, and a file's address IS the
|
||||
* tab's content identity: the same address is the same tab. Two scopes exist:
|
||||
*
|
||||
* - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path
|
||||
* relative to that session's workspace root OR by its absolute path kept
|
||||
* absolute inside the session scope (`src/a.ts` vs `/outside/a.ts` — the
|
||||
* leading `/` survives; the host resolves it against the root it holds for
|
||||
* the session);
|
||||
* - `dsh-resource://file/absolute/<path>` names a file by its absolute path
|
||||
* with the leading `/` dropped (`absolute/home/me/x.txt`; Windows
|
||||
* `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment,
|
||||
* `absolute//server/share/x.txt`). It carries no session. `fileAddressFor`
|
||||
* no longer produces this scope on DSH 0.1.5-alpha.2, but it must keep
|
||||
* parsing: legacy and third-party addresses still spell it.
|
||||
*
|
||||
* Every id and path segment is component-encoded, so a name carrying `#`, `?`
|
||||
* or a space survives the round trip; `:` stays literal so a drive letter
|
||||
* reads as written.
|
||||
*
|
||||
* The plugin parses these addresses itself instead of importing
|
||||
* `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate
|
||||
* forbids value-importing an unlisted `@deepseek-ai/*` package. This module
|
||||
* mirrors that package's implementation — `packages/util/workspace-path/src/file-address.ts`
|
||||
* and `fileAddressFor` in `packages/util/workspace-path/src/index.ts` in
|
||||
* DSH 0.1.5-alpha.2 (github.com/deepseek-ai/deepseek-harness, tag
|
||||
* `dsh-v0.1.5-alpha.2`) — and is pinned by tests/resource-address.spec.ts.
|
||||
*/
|
||||
|
||||
/** The scheme and type every file address opens with. */
|
||||
export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
|
||||
|
||||
/**
|
||||
* A file resource address, in one of two scopes.
|
||||
*/
|
||||
export type FileAddress =
|
||||
| {
|
||||
readonly scope: 'session'
|
||||
/** The session whose workspace root resolves the path. */
|
||||
readonly sessionId: string
|
||||
/** Absolute or workspace-relative `/`-separated path; empty for the workspace root itself. */
|
||||
readonly path: string
|
||||
}
|
||||
| {
|
||||
readonly scope: 'absolute'
|
||||
/** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a drive, `//server/share/a` for UNC. */
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** Component-encode one id or path segment, keeping `:` literal for drive letters. */
|
||||
function encodeSegment(segment: string): string {
|
||||
return encodeURIComponent(segment).replace(/%3A/gi, ':')
|
||||
}
|
||||
|
||||
/** Encode a `/`-separated path segment by segment. */
|
||||
function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeSegment).join('/')
|
||||
}
|
||||
|
||||
/** Whether a decoded first path segment is a Windows drive (`C:`). */
|
||||
function isDriveSegment(segment: string | undefined): boolean {
|
||||
return segment !== undefined && /^[A-Za-z]:$/.test(segment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the address of a file read through one session.
|
||||
* @param sessionId - the session whose workspace root resolves the path.
|
||||
* @param path - absolute or workspace-relative path; backslashes are normalized
|
||||
* to `/`, and leading `./` prefixes are dropped (a leading `/` is KEPT: an
|
||||
* absolute path stays absolute inside the session scope).
|
||||
* @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
|
||||
*/
|
||||
export function sessionFileAddress(sessionId: string, path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
|
||||
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the address of a file by its absolute path.
|
||||
* @param path - absolute path; backslashes are normalized to `/` and the leading `/`
|
||||
* is dropped, except that a UNC path keeps one empty first segment.
|
||||
* @returns the `dsh-resource://file/absolute/<path>` address.
|
||||
*/
|
||||
export function absoluteFileAddress(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const unc = normalized.startsWith('//')
|
||||
const absolute = normalized.replace(/^\/+/, '')
|
||||
return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file address back into its parts without resolving `.` or `..`.
|
||||
* Query and fragment suffixes are ignored; encoded path segments are decoded.
|
||||
* @param address - a candidate address.
|
||||
* @returns the parts, or `undefined` when the string is not a
|
||||
* `dsh-resource://file/` URI in a known scope with a path, or a segment is
|
||||
* not validly encoded.
|
||||
*/
|
||||
export function parseFileAddress(address: string): FileAddress | undefined {
|
||||
try {
|
||||
if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined
|
||||
const end = address.search(/[?#]/)
|
||||
const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).split('/')
|
||||
if (scope === 'session') {
|
||||
const [id, ...segments] = rest
|
||||
if (id === undefined || id === '' || segments.length === 0) return undefined
|
||||
return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') }
|
||||
}
|
||||
if (scope === 'absolute') {
|
||||
// An empty first segment with more behind it is a UNC path's `//`; alone it is no path.
|
||||
const unc = rest[0] === '' && rest.length > 1
|
||||
const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent)
|
||||
if (segments.length === 0 || segments[0] === '') return undefined
|
||||
if (unc) return { scope, path: `//${segments.join('/')}` }
|
||||
return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` }
|
||||
}
|
||||
return undefined
|
||||
} catch {
|
||||
// `decodeURIComponent` throws URIError on a malformed escape.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a path is absolute in either spelling the host accepts. */
|
||||
function isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
/**
|
||||
* The address for a path as a caller holds it: ALWAYS session-scoped. A
|
||||
* relative path, or an absolute path inside the session's workspace, becomes
|
||||
* the relative spelling; an absolute path outside it, or one whose workspace
|
||||
* root is unknown, keeps its absolute path in that session's address. (Mirror
|
||||
* of `fileAddressFor` — on alpha.2 this helper no longer produces the
|
||||
* `absolute` scope, though such addresses still parse.)
|
||||
* @param sessionId - the session the path is read in.
|
||||
* @param cwd - that session's workspace root, when known.
|
||||
* @param path - absolute or workspace-relative path, either separator spelling.
|
||||
* @returns the `dsh-resource://file/…` address.
|
||||
*/
|
||||
export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
if (!isAbsolutePath(normalized)) return sessionFileAddress(sessionId, normalized)
|
||||
const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
|
||||
if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
|
||||
return sessionFileAddress(sessionId, normalized)
|
||||
}
|
||||
+182
-89
@@ -22,11 +22,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
|
||||
leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer,
|
||||
leafWithTab, openTabInBottomPane, patchTab, tabOpenIn,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import { extOf } from './paths.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
@@ -163,6 +162,17 @@ export interface TabDescriptor {
|
||||
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
/**
|
||||
* One-line description of what this tab shows, rendered under the title in
|
||||
* the host's new-tab list (DSH's native right Sidebar guide page). DSH
|
||||
* 0.1.5-rc.1 renders descriptions only while the guide lists at most 4
|
||||
* entries — a longer list drops every description and shows titles alone —
|
||||
* and a descriptor that declares none renders the title by itself (the
|
||||
* host no longer substitutes a generic fallback, so declare the real
|
||||
* purpose of the page). Evaluated at render time, so a function follows
|
||||
* the active locale.
|
||||
*/
|
||||
description?: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + menu sort order (ascending); default 100. */
|
||||
order?: number
|
||||
@@ -339,6 +349,57 @@ export interface OpenTabSeed {
|
||||
url?: string
|
||||
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
|
||||
meta?: unknown
|
||||
/**
|
||||
* Where the open lands. `'right'` (the default) is DSH's right Sidebar —
|
||||
* the plugin's content is registered there as native tab types; `'bottom'`
|
||||
* is the plugin's own bottom workbench. Only the plugin's own flows pass
|
||||
* `'bottom'` (the bottom panel's + menu, the auto-terminal).
|
||||
*/
|
||||
target?: 'right' | 'bottom'
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin-side seed a native right-Sidebar tab carries in its navigation
|
||||
* params (the native surface passes them back on every navigation).
|
||||
*/
|
||||
export interface NativeTabParams {
|
||||
/** Overrides the descriptor's title for this instance. */
|
||||
title?: string
|
||||
/** A file path (the editor window's content seed). */
|
||||
path?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
url?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
diff?: SidebarTab['diff']
|
||||
/** JSON-serializable custom state carried on the synthetic record. */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's write face over DSH's native right Sidebar.
|
||||
*
|
||||
* Installed by the client half ({@link ./native/surface.ts}) so the service —
|
||||
* and therefore every consumer of `ctx.betterSidebar` — keeps speaking the
|
||||
* plugin's own vocabulary while the content lands natively. Without it the
|
||||
* service writes into the plugin's own layout (the pre-0.1.5 behavior, which
|
||||
* the bottom workbench still uses).
|
||||
* @internal Not part of the consumer contract.
|
||||
*/
|
||||
export interface SidebarSurface {
|
||||
/** Open a page type in one session's native surface. */
|
||||
openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void
|
||||
/** Open a resource address in one session's native surface. */
|
||||
openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void
|
||||
/** The file address of one path (the native surface owns the grammar). */
|
||||
fileAddress(sessionId: string, cwd: string | undefined, path: string): string
|
||||
/** Close one native tab; the closed record's type/title, or undefined when the id is not native. */
|
||||
close(sessionId: string, tabId: string): { type: string; title: string } | undefined
|
||||
/** Patch a native tab's plugin-side record; false when it is not native. */
|
||||
update(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): boolean
|
||||
/** Focus a native tab; false when it is not native. */
|
||||
activate(tabId: string): boolean
|
||||
/** Whether a tab id belongs to the native surface. */
|
||||
has(tabId: string): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,12 +440,10 @@ export interface BetterSidebarService {
|
||||
* without switching the UI's active session; when absent the open lands
|
||||
* in the currently active session (the pre-0.12 behavior).
|
||||
*
|
||||
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
|
||||
* panel hosting the landing pane is collapsed, it is expanded
|
||||
* automatically (the right panel by default, the bottom panel when the
|
||||
* active pane lives there; on narrow viewports the merged drawer opens).
|
||||
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
|
||||
* the panel behavior is their caller's business.
|
||||
* Every open lands in the bottom workbench and expands it (the workbench
|
||||
* is the plugin's only own surface; the right column is DSH's native
|
||||
* Sidebar). An open carrying a `path` or `url` goes through the native
|
||||
* surface instead, which never touches this state.
|
||||
*
|
||||
* Note: `available` gates the + menu's disabled state only — it does NOT
|
||||
* refuse `openTab` (only the settings disable switch does).
|
||||
@@ -426,6 +485,11 @@ export interface BetterSidebarService {
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
/**
|
||||
* Install (or clear) the native right-Sidebar write face.
|
||||
* @internal Called once by the client half; not part of the consumer API.
|
||||
*/
|
||||
setSurface(surface: SidebarSurface | undefined): void
|
||||
}
|
||||
|
||||
/** The file name of a path (both separators). */
|
||||
@@ -465,7 +529,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.18.1'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.19.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
@@ -480,9 +544,10 @@ export const SIDEBAR_SERVICE_VERSION = '0.18.1'
|
||||
* - '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.
|
||||
*
|
||||
* v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5
|
||||
* owns the right column, so the plugin keeps only its bottom workbench).
|
||||
* Consumers must not gate on it any more.
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
@@ -495,7 +560,6 @@ export const SIDEBAR_FEATURES = [
|
||||
'pluginSettings',
|
||||
'urlTarget',
|
||||
'settingSelect',
|
||||
'floatWindows',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
@@ -516,6 +580,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const tabs = new Map<string, TabDescriptor>()
|
||||
const viewers = new Map<string, FileViewerDescriptor>()
|
||||
const listeners = new Set<() => void>()
|
||||
/** The native right-Sidebar write face, installed by the client half. */
|
||||
let surface: SidebarSurface | undefined
|
||||
|
||||
const notify = (): void => {
|
||||
for (const fn of [...listeners]) fn()
|
||||
@@ -608,6 +674,61 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (targetSessionId === undefined) return
|
||||
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
|
||||
// ── Native right Sidebar ──────────────────────────────────────────────
|
||||
// With the native surface installed, every open except an explicit
|
||||
// bottom-panel one lands there: a file path becomes a resource address
|
||||
// (the native registry routes it to the plugin's file type), a path-less
|
||||
// editor open becomes the `files` page kind, and everything else becomes
|
||||
// a page open carrying the seed as navigation params.
|
||||
if (surface !== undefined && seed.target !== 'bottom') {
|
||||
const state = store.getSnapshot().state
|
||||
// The descriptor's own factory mints what a view needs beyond the seed:
|
||||
// the side chat's thread bootstrap / reattach meta, the terminal's
|
||||
// per-instance title. A `null` return refuses the open (terminal cap).
|
||||
const minted = descriptor.createTab === undefined || state === undefined
|
||||
? undefined
|
||||
: descriptor.createTab(state)
|
||||
if (minted === null) return
|
||||
const title = seed.title ?? minted?.tab.title
|
||||
?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title)
|
||||
// Multi-instance kinds (terminal / browser / side chat / diff) mint a
|
||||
// fresh tab per open; single-instance kinds focus the existing one.
|
||||
const revealIfOpened = descriptor.createTab === undefined
|
||||
const synthetic: SidebarTab = {
|
||||
id: seed.id ?? minted?.tab.id ?? seed.type,
|
||||
type: seed.type,
|
||||
title,
|
||||
...(seed.path === undefined ? {} : { path: seed.path }),
|
||||
...(seed.diff === undefined ? {} : { diff: seed.diff }),
|
||||
...(seed.meta === undefined && minted?.tab.meta === undefined ? {} : { meta: seed.meta ?? minted?.tab.meta }),
|
||||
}
|
||||
if (seed.path !== undefined) {
|
||||
surface.openResource({
|
||||
sessionId: targetSessionId,
|
||||
address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path),
|
||||
revealIfOpened: true,
|
||||
})
|
||||
} else if (seed.type === 'editor') {
|
||||
// The path-less editor window IS the file explorer.
|
||||
surface.openTab({ sessionId: targetSessionId, kind: 'files', params: {}, revealIfOpened: true })
|
||||
} else {
|
||||
surface.openTab({
|
||||
sessionId: targetSessionId,
|
||||
kind: seed.type,
|
||||
params: {
|
||||
title,
|
||||
...(seed.url === undefined ? {} : { url: seed.url }),
|
||||
...(seed.diff === undefined ? {} : { diff: seed.diff }),
|
||||
...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }),
|
||||
},
|
||||
revealIfOpened,
|
||||
})
|
||||
}
|
||||
// The native surface reports one open event, not create-vs-focus, so a
|
||||
// lifecycle consumer hears onOpen (documented in the guide).
|
||||
safeCall(() => descriptor.onOpen?.(synthetic, callbackScope))
|
||||
return
|
||||
}
|
||||
// Whether this open targets a session that is NOT the one on screen: a
|
||||
// targeted open must not auto-expand panels the user cannot see (the
|
||||
// expansion is about landing "in sight" for the CURRENT viewer).
|
||||
@@ -617,6 +738,10 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
||||
let created: SidebarTab | undefined
|
||||
let activated: SidebarTab | undefined
|
||||
// A bottom-targeted open lands in the bottom workbench's own pane; the
|
||||
// right tree it would otherwise follow is no longer rendered (DSH's
|
||||
// native sidebar owns the right column).
|
||||
const land = openTabInBottomPane
|
||||
const reducer = (state: SidebarState): SidebarState => {
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
@@ -625,7 +750,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const result = descriptor.createTab(state)
|
||||
if (result === null) return state
|
||||
tab = result.tab
|
||||
next = applyDedupe(state, result.tab, descriptor)
|
||||
next = applyDedupe(state, result.tab, descriptor, land)
|
||||
if (result.patch !== undefined) next = { ...next, ...result.patch }
|
||||
} else {
|
||||
tab = {
|
||||
@@ -638,7 +763,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
next = applyDedupe(state, tab, descriptor, land)
|
||||
}
|
||||
// Classify the landing against the INPUT state FIRST: a FOCUS fires
|
||||
// onActivate with the tab that is active NOW; a real creation fires
|
||||
@@ -649,8 +774,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// tab that never closes.
|
||||
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 inputTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
const existedByKey = key !== undefined
|
||||
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
const existedById = tabOpenIn(state, tab.id)
|
||||
@@ -670,57 +794,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
if (isCreation) {
|
||||
// Resolve the ACTUAL landed tab — the url patch mints a new object,
|
||||
// so the callback must see the tab that was really inserted.
|
||||
const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
const landedTabs = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
|
||||
} 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))
|
||||
const candidates = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
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
|
||||
// (panelOpen) is the only lever; on wide viewports the landing pane's
|
||||
// own panel opens — the bottom panel when the active pane lives in the
|
||||
// bottom tree, else the right panel. Type-only opens (+ menu,
|
||||
// agent-terminal auto-tabs) never expand (the panel behavior is their
|
||||
// caller's business). The check runs on the post-dedupe state, so a
|
||||
// content open that merely FOCUSES an existing tab expands the panel
|
||||
// too — the open must never land out of sight. Opens targeted at an
|
||||
// INACTIVE session never expand (nothing is in sight for the user).
|
||||
if (
|
||||
!targetsInactiveSession
|
||||
&& typeof window !== 'undefined'
|
||||
&& (seed.path !== undefined || seed.url !== undefined)
|
||||
) {
|
||||
if (isNarrowWidth(window.innerWidth)) {
|
||||
if (!landed.panelOpen) return togglePanel(landed)
|
||||
} else {
|
||||
const hostKey = treeOf(landed, landed.activePane ?? '')
|
||||
if (hostKey === 'bottomSplits') {
|
||||
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
|
||||
} else if (!landed.panelOpen) {
|
||||
return togglePanel(landed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return landed
|
||||
// Every open that lands in the workbench is visible: a creation
|
||||
// expands it (openTabInBottomPane) and so does a FOCUS (the dedupe/id
|
||||
// reducers only activate the tab). An open targeted at an INACTIVE
|
||||
// session expands THAT session's workbench — it is waiting for the
|
||||
// user when they switch to it.
|
||||
return landed.bottomOpen ? landed : { ...landed, bottomOpen: true }
|
||||
}
|
||||
// A scope targeting ANOTHER session lands the open there without
|
||||
// switching the UI; a scope naming the active session (or no scope)
|
||||
@@ -735,19 +825,27 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string, scope?: SessionScope): void => {
|
||||
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (surface !== undefined && sessionId !== undefined) {
|
||||
const closedNative = surface.close(sessionId, tabId)
|
||||
if (closedNative !== undefined) {
|
||||
const descriptor = tabs.get(closedNative.type)
|
||||
if (descriptor !== undefined) {
|
||||
safeCall(() => descriptor.onClose?.(
|
||||
{ id: tabId, type: closedNative.type as TabType, title: closedNative.title },
|
||||
scope ?? { sessionId },
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
let closed: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// 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)
|
||||
const leaf = leafWithTab(state.bottomSplits, tabId)
|
||||
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
@@ -769,6 +867,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
|
||||
/** Patch an open tab's display fields (a missing tab id is a no-op). */
|
||||
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
|
||||
if (surface?.update(tabId, patch) === true) return
|
||||
store.reduce((state) => patchTab(state, tabId, {
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
@@ -778,18 +877,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
|
||||
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
|
||||
const activateTab = (tabId: string, scope?: SessionScope): void => {
|
||||
if (surface?.activate(tabId) === true) return
|
||||
let activated: SidebarTab | undefined
|
||||
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)
|
||||
const leaf = leafWithTab(state.bottomSplits, tabId)
|
||||
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return activateTabReducer(state, paneId, tabId)
|
||||
})
|
||||
@@ -829,37 +923,36 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
updateTab,
|
||||
activateTab,
|
||||
openFile,
|
||||
setSurface: (next: SidebarSurface | undefined) => { surface = next },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
|
||||
* same type exists, focus it; otherwise land the tab through
|
||||
* `openTabInActivePane` (the id safety net + active-pane landing are that
|
||||
* reducer's job — not re-implemented here).
|
||||
* same type exists, focus it; otherwise land the tab through `land` (the id
|
||||
* safety net + landing are that reducer's job — not re-implemented here).
|
||||
* `single: true` resolves to the id-key sugar when no explicit key is given.
|
||||
*/
|
||||
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
|
||||
function applyDedupe(
|
||||
state: SidebarState,
|
||||
tab: SidebarTab,
|
||||
descriptor: TabDescriptor,
|
||||
land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInBottomPane,
|
||||
): SidebarState {
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
// The scan covers BOTH trees: opening a single-instance tab from the
|
||||
// bottom panel focuses an existing instance wherever it lives.
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
for (const leaf of allLeaves(state.bottomSplits)) {
|
||||
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)
|
||||
return land(state, tab)
|
||||
}
|
||||
|
||||
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
|
||||
/** Find which pane hosts a tab id ('' if none). */
|
||||
function findPaneIdOf(state: SidebarState, tabId: string): string {
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
for (const leaf of allLeaves(state.bottomSplits)) {
|
||||
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
|
||||
}
|
||||
return state.activePane ?? ''
|
||||
|
||||
+26
-327
@@ -69,41 +69,6 @@
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* The persistent expand/collapse cluster at the top-right corner: two rail
|
||||
controls side by side (bottom panel's glyph LEFT of the right panel's).
|
||||
Always pinned to the viewport corner — inside the right panel's top-right
|
||||
while it is open, sitting flush in the 34px tab strip (28px buttons at
|
||||
top: 3) whose right end it really squeezes. Host-internal z-index 45
|
||||
(above the panels' 40); the host's global layer is z-index 25, so the
|
||||
whole cluster stays below DSH's ui-cordis dynamic-plugin panel (30) and
|
||||
the app's overlay stack (menus/tooltips/modals sit at 100+), so a cordis
|
||||
approval popup is never hidden behind the workbench (issue #52). Absolute
|
||||
within the panel host (the host's inset: 0 makes viewport coordinates
|
||||
identical). */
|
||||
.toggleCluster {
|
||||
position: absolute;
|
||||
/* env() resolves to 0 on devices without a top inset (desktops). */
|
||||
top: calc(3px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
pointer-events: auto;
|
||||
/* The cluster recenters between the panel's tab strip (top: 3) and DSH's
|
||||
session header row (top: 14, see the collapsed rule below) on every
|
||||
expand/collapse — ride the panel slide's own duration/easing so the two
|
||||
moves stay in lockstep instead of the cluster snapping. */
|
||||
transition: top var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The tab strip of the OPEN right panel reserves the cluster's width at its
|
||||
right end, so the tabs and the + menu genuinely yield space to the cluster
|
||||
(never hiding under it). */
|
||||
.panel:not(.panelHidden) .tabBar {
|
||||
padding-right: 72px;
|
||||
}
|
||||
|
||||
/* The closed-state affordance is the native rail icon control: a plain
|
||||
28px circle (the app's icon-button size — the cluster must stay inside
|
||||
the 34px tab-strip band so it never covers the pane headers below),
|
||||
@@ -136,75 +101,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.panel {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
/* Full height: the bottom panel squeezes only the center column, so the
|
||||
right panel never gives up any vertical position. */
|
||||
bottom: 0;
|
||||
/* Host-internal z-index 40 (sibling of the bottom panel's 40; the two
|
||||
never overlap — the bottom panel's right edge is this panel's left
|
||||
edge — and both sit below the toggle cluster's 45). The absolute
|
||||
global layer comes from the host ([data-dsh-panel-host], 25) — the
|
||||
whole stack stays above the app's in-flow content and below DSH's
|
||||
ui-cordis dynamic-plugin panel (30) and the app's overlay stack (100+),
|
||||
so menus/tooltips/modals always win over the panels (issue #52). The
|
||||
corner handle is a child and stacks within the panel. */
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Re-enable interaction inside the click-transparent panel host. */
|
||||
pointer-events: auto;
|
||||
/* The generic card surface (see the root contract above). Never
|
||||
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
|
||||
app's own left nav and a panel consuming it loses its fill or clashes
|
||||
with labels (#60/#105/#106). */
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Bottom safe-area (notched phones): the drawer's bottom content clears
|
||||
the home indicator; 0 on desktops. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/* Expand/collapse slides the panel; fullscreen animates the width. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
|
||||
.panelHidden {
|
||||
transform: translateX(102%);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
visibility 0s linear var(--ds-transition-duration-slow);
|
||||
}
|
||||
|
||||
/* Width drags write at pointer cadence; easing would detach the edge. */
|
||||
.panel[data-dragging] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.panelResize {
|
||||
position: absolute;
|
||||
/* Centered on the panel edge (an 8px hit strip straddling the border, the
|
||||
same pattern as the host's own frame handles). */
|
||||
left: -4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.panelResizeActive {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
.panelBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -263,7 +159,6 @@
|
||||
will-change: transform is only live while a drag is (the body attribute
|
||||
toggles with the drag state), promoting the panels to their own layer
|
||||
without paying the compositing cost at rest. */
|
||||
.panel,
|
||||
.bottomPanel {
|
||||
contain: layout style;
|
||||
}
|
||||
@@ -324,148 +219,6 @@ 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);
|
||||
}
|
||||
|
||||
/* Mirrors .paneContent's column-flex contract: the tab cell must hand its
|
||||
child a definite WIDTH via align-stretch. A row flex (the default) sizes
|
||||
the main axis (width) from content — intrinsic-zero hosts (e.g. a plugin
|
||||
tab UI) collapse to a 0px-wide sliver and paint nothing (the blank
|
||||
whale-report window). */
|
||||
.floatContent {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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
|
||||
@@ -475,10 +228,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
|
||||
wrappers) have no blanket button rule. The property is a no-op in plain
|
||||
browsers and in shells without drag regions — safe everywhere. */
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tabBar,
|
||||
.floatHeader {
|
||||
.tabBar {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -487,67 +238,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
/* Windows frameless / hidden-title-bar windows draw the native caption
|
||||
strip (minimize / maximize / close) at the window's top-right corner,
|
||||
OVER the web content. When the user enables the "position compatibility"
|
||||
pref, the sidebar yields the strip: the toggle cluster drops below it and
|
||||
the right panel's content starts below it — the panel's surface still
|
||||
spans the full height, so the caption buttons float over empty sidebar
|
||||
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
|
||||
(`--dsh-title-bar-strip`, written by the Sidebar shell from
|
||||
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
|
||||
sits at the window's bottom, never under the top strip. */
|
||||
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
|
||||
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
|
||||
}
|
||||
|
||||
:global(body[data-dsh-title-bar-compat]) .panel {
|
||||
/* The caption strip is drawn over the panel's own top edge: keep the
|
||||
surface full-height and push the content (the tab strip) below the
|
||||
strip instead. .panelResize is absolutely positioned and unaffected. */
|
||||
padding-top: var(--dsh-title-bar-strip, 40px);
|
||||
}
|
||||
|
||||
/* ── Collapsed cluster alignment with the DSH web session header ───────── */
|
||||
|
||||
/* While the right panel is open, the cluster rides the panel's 34px tab
|
||||
strip (28px buttons at top: 3). Collapsed, it floats over DSH's own
|
||||
session header instead — whose controls (title, "Session log" capsule)
|
||||
are vertically centered at 28px on both desktop and narrow viewports.
|
||||
top: 3 then strands the cluster ~11px above that row; re-center the
|
||||
28px buttons at 14px. The attribute disappears while the panel is open,
|
||||
so the tab-strip alignment above is untouched. With position
|
||||
compatibility active the DSH header sits below the caption strip, hence
|
||||
strip + 14. */
|
||||
:global(body[data-dsh-sidebar-collapsed]) .toggleCluster {
|
||||
top: calc(14px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
:global(body[data-dsh-sidebar-collapsed][data-dsh-title-bar-compat]) .toggleCluster {
|
||||
top: calc(var(--dsh-title-bar-strip, 40px) + 14px);
|
||||
}
|
||||
|
||||
/* The shared corner (both panels open): the intersection of the right
|
||||
panel's left edge and the bottom panel's top edge. Horizontal drags
|
||||
resize the width, vertical drags the height — the two panels drag
|
||||
against each other. Rendered INSIDE the right panel and positioned
|
||||
relative to it (left edge + the bottom panel's height from the layout
|
||||
variable), so no JS-written viewport coordinates are involved. The
|
||||
z-index is panel-local (above the panel's content; the panel itself sits
|
||||
at 40, below the app's overlay stack). */
|
||||
.cornerHandle {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
z-index: 2;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.cornerHandle:hover,
|
||||
.cornerHandle[data-dragging] {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
pref, the Sidebar shell publishes the strip height as
|
||||
`--dsh-title-bar-strip` plus `body[data-dsh-title-bar-compat]` for the
|
||||
user's own CSS; the plugin's remaining chrome (the bottom workbench) sits
|
||||
at the window's bottom and is never under the top strip. */
|
||||
/* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px
|
||||
circle, secondary label, hover fill; no border so the icon alone is the
|
||||
affordance. */
|
||||
@@ -2332,6 +2026,24 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── Native right-Sidebar tab-body host ──────────────────────────────────
|
||||
DSH's native tab body host (`.paneBody` in dsh-client-ui-dockkit) is a
|
||||
BLOCK scroller with a definite height, not a flex container — the host's
|
||||
own tab bodies (TextPreview, FilesBody) declare `height: 100%` on their
|
||||
root for exactly this reason. Our tab roots declare `flex: 1; min-height:
|
||||
0`, which collapses to content height in that block host (the side-chat
|
||||
composer would float mid-pane). A column flex host of `height: 100%`
|
||||
restores the `flex: 1` semantics our tab roots rely on, and gives
|
||||
third-party descriptors' components the same box inside the native pane.
|
||||
The wrapper div carries `data-dsh-native-tab-host` so the e2e lane can
|
||||
assert the fill (mirroring `data-dsh-better-sidebar`). */
|
||||
.nativeTabHost {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Diff tab (generic states + shell; the changes tab owns the rest in
|
||||
changes.module.css, and the diff rows themselves live in
|
||||
diff/diff.module.css) ────────────────────────────────────────────────── */
|
||||
@@ -2466,11 +2178,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
|
||||
/* Reduced motion: no slides, fades or color transitions. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel,
|
||||
.panelHidden,
|
||||
.bottomPanel,
|
||||
.bottomPanelHidden,
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tab,
|
||||
.tabBarPlus,
|
||||
@@ -2487,20 +2196,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
|
||||
|
||||
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
|
||||
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
|
||||
mobile layout — one full-screen drawer (the bottom panel's tabs were
|
||||
migrated into the right tree), one toggle button. The structural
|
||||
switches (which panels render, the migration) are JS-driven via
|
||||
useNarrowViewport; this block only adapts the pure style details. */
|
||||
strictly below 768px (i.e. max-width: 767px) get the phone-width tab
|
||||
rhythm; the bottom workbench keeps its own geometry (it spans the center
|
||||
column at every width). */
|
||||
@media (max-width: 767px) {
|
||||
/* The toggle cluster holds a single button on narrow viewports (the
|
||||
bottom panel button is not rendered), so the open panel's tab strip
|
||||
reserves 40px instead of the desktop 72px — the tabs and + menu still
|
||||
yield space to the one remaining control. */
|
||||
.panel:not(.panelHidden) .tabBar {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* Narrower tabs: a phone-width drawer fits more open tabs before the
|
||||
strip scrolls. */
|
||||
.tab {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The bottom workbench's expand/collapse control, registered into DSH's
|
||||
* session header utilities list (`conversation.session.header.utilities`).
|
||||
*
|
||||
* DSH 0.1.5 draws its own right-Sidebar expand button in the header's corner
|
||||
* slot (`conversation.session.header.corner`, a SINGLE slot owned by the
|
||||
* native sidebar package), so the plugin cannot take that seat. The utilities
|
||||
* list sits immediately left of it — the same row as the host's "Session
|
||||
* log" capsule — which is where a dock toggle belongs: in flow, so it never
|
||||
* overlaps host chrome and needs no absolute positioning or measurement.
|
||||
*
|
||||
* Registration goes through `slots.inject` because the declaration may not be
|
||||
* on the ledger yet at plugin activation (the ui-conversation parent entry
|
||||
* declares it); the callback re-runs if the declaration collapses and is
|
||||
* re-declared. `order: 10` places the button after the host's own utilities.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import { BottomDockToggle } from '../Sidebar.tsx'
|
||||
|
||||
/** Register the header toggle; returns the disposer. */
|
||||
export function registerBottomToggle(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'dsh-better-sidebar:bottom-toggle',
|
||||
order: 10,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
}, () => <BottomDockToggle store={store} />))
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Free windows (extracted from Sidebar.tsx, behavior identical): the
|
||||
* drag-out gesture that floats a tab onto the conversation column, plus the
|
||||
* render layer for the floating windows and its drop-zone hint overlay.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import {
|
||||
dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
|
||||
type FloatWindow, type SidebarStore, type SidebarTab,
|
||||
} from '../state.ts'
|
||||
import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
|
||||
import { FreeWindow } from '../FreeWindow.tsx'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The dashed drop-zone hint's geometry (viewport coordinates). */
|
||||
export interface FloatDropHint {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 hook 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).
|
||||
*/
|
||||
export function useFloatDragout(input: {
|
||||
narrow: boolean
|
||||
sessionId: string | undefined
|
||||
store: SidebarStore
|
||||
/** The AppFrame center column (from useCenterColumn); stable ref object. */
|
||||
centerColRef: { readonly current: HTMLElement | null }
|
||||
}): { floatHint: FloatDropHint | null } {
|
||||
const { narrow, sessionId, store, centerColRef } = input
|
||||
const [floatHint, setFloatHint] = useState<FloatDropHint | 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)
|
||||
}
|
||||
// centerColRef is a stable ref object from useCenterColumn (it lost its
|
||||
// useRef provenance crossing the hook boundary, so it is listed here).
|
||||
}, [narrow, sessionId, store, centerColRef])
|
||||
|
||||
return { floatHint }
|
||||
}
|
||||
|
||||
/**
|
||||
* The free-window render layer: 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. Renders a
|
||||
* fragment so the DOM is exactly the floats followed by the drag-out hint.
|
||||
*/
|
||||
export function FreeWindowLayer(props: {
|
||||
floats: readonly FloatWindow[]
|
||||
hint: FloatDropHint | null
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
|
||||
getTabIcon: (tab: SidebarTab) => ReactNode
|
||||
store: SidebarStore
|
||||
ctx: Context
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
|
||||
return (
|
||||
<>
|
||||
{floats.map(float => (
|
||||
<FreeWindow
|
||||
key={float.id}
|
||||
float={float}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
|
||||
getTabIcon={getTabIcon}
|
||||
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.get('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).
|
||||
*/}
|
||||
{hint !== null && (
|
||||
<div
|
||||
className={css.floatDropHint}
|
||||
style={{ left: hint.left, top: hint.top, width: hint.width, height: hint.height }}
|
||||
>
|
||||
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
|
||||
* the bottom panel spans ONLY the app shell's center column ("squeezes the
|
||||
* agent output area") — it starts at the app sidebar's right edge and ends
|
||||
* at the details column's left edge (the details column sits between the
|
||||
* center and the right panel). Measured directly from the AppFrame's center
|
||||
* column DOM (the parent of the [data-slot="conversation"] wrapper —
|
||||
* layout.css's center column) so the bottom panel tracks the column's real
|
||||
* horizontal edges — including the animated AppFrame padding reservation
|
||||
* while the right panel opens/closes; a frame that never appears keeps the
|
||||
* the bottom workbench spans ONLY the app shell's center column ("squeezes
|
||||
* the agent output area") — it starts at the app sidebar's right edge and
|
||||
* ends at the details column's left edge. Measured directly from the
|
||||
* AppFrame's center column DOM (center-column.ts anchors on the conversation
|
||||
* slot host — `main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on
|
||||
* alpha.1 — and walks up the transparent `display: contents` slot hosts to
|
||||
* the column; layout.css's center column) so the workbench tracks the
|
||||
* column's
|
||||
* real horizontal edges, including the animated track moves while DSH's
|
||||
* native right Sidebar opens/closes; a frame that never appears keeps the
|
||||
* initial zero-size fallback (the panel renders at 0 width until measured).
|
||||
* The rect lives in a REF (not state): the open/close transition resizes
|
||||
* the center column EVERY frame for its duration, and reacting per frame
|
||||
@@ -68,15 +70,17 @@ export function useCenterColumn(
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | undefined
|
||||
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
|
||||
// [data-slot] containers: the conversation slot wrapper
|
||||
// ([data-slot="conversation"]) sits directly inside the center column,
|
||||
// so its parent IS that column — no hashed-class or positional
|
||||
// dependency (layout.css uses the same anchor). The shell swaps the
|
||||
// boot page for the AppFrame only AFTER boot settles, so the first
|
||||
// query may miss it. Never give up: watch #root's subtree (the swap and
|
||||
// HMR re-renders mutate it) and re-run this locator — querying once and
|
||||
// bailing would strand the panel at the zero-size fallback forever
|
||||
// (observed: a 1px sliver at the viewport's left edge).
|
||||
// [data-slot] containers: the conversation slot host (key
|
||||
// `main.conversation` on 0.1.5-alpha.2+, `conversation` on alpha.1)
|
||||
// sits inside the center column behind transparent `display: contents`
|
||||
// slot hosts, so the resolver walks up to the first ancestor that
|
||||
// renders a box — no hashed-class or positional dependency
|
||||
// (layout.css uses the same anchor). The shell swaps the boot page for
|
||||
// the AppFrame only AFTER boot settles, so the first query may miss it.
|
||||
// Never give up: watch #root's subtree (the swap and HMR re-renders
|
||||
// mutate it) and re-run this locator — querying once and bailing would
|
||||
// strand the panel at the zero-size fallback forever (observed: a 1px
|
||||
// sliver at the viewport's left edge).
|
||||
const locate = (): void => {
|
||||
if (disposed) return
|
||||
// Hot path (#403): streaming output mutates #root at token cadence.
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Context, SidebarSessionList } from '../../context-types.ts'
|
||||
import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts'
|
||||
import { isNarrowWidth } from '../breakpoints.ts'
|
||||
import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
|
||||
import { detectNewDirectSubagent } from '../subagent-detect.ts'
|
||||
import { detectNewJob } from '../subagent-jobs.ts'
|
||||
import { t } from '../locales.ts'
|
||||
@@ -197,17 +196,7 @@ export function useHostFeeds(feeds: {
|
||||
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
// Read the viewport when the delayed activation fires: a resize while
|
||||
// the debounce is armed must not let background activity force the
|
||||
// narrow full-screen drawer open over the chat.
|
||||
if (!isNarrowWidth(window.innerWidth)) {
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
}
|
||||
// Choose the right panel as the landing pane for a newly created Tasks
|
||||
// tab. Single-instance dedupe still activates an existing pane tab in
|
||||
// place or raises an existing free window.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
|
||||
}, AUTO_OPEN_DEBOUNCE_MS)
|
||||
autoOpenPendingRef.current = { baseline, timer }
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
@@ -237,11 +226,7 @@ export function useHostFeeds(feeds: {
|
||||
if (!detectNewJob(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenJobs) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
if (!isNarrowWidth(window.innerWidth)) {
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
}
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
@@ -260,9 +245,7 @@ export function useHostFeeds(feeds: {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
return { subagentJumpRef }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
|
||||
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
|
||||
* leaf of the right panel's split tree, and the workbench actions are
|
||||
* leaf of the bottom workbench's split tree, and the workbench actions are
|
||||
* wrapped so virtual ids route back to the HOME session. The shell only
|
||||
* consumes the augmented tree and the wrapped actions.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
|
||||
agentUuidOf, closeTab, isAgentTabId, leafWithTab, setTabPin,
|
||||
type SidebarSnapshot, type SidebarStore, type SplitNode,
|
||||
} from '../state.ts'
|
||||
import {
|
||||
@@ -29,8 +29,8 @@ export function usePinnedTabs(input: {
|
||||
|
||||
/**
|
||||
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
|
||||
* inject as VIRTUAL tabs into the first leaf of the right panel's split
|
||||
* tree. The virtual tabs have unique ids (prefixed with the home session)
|
||||
* inject as VIRTUAL tabs into the first leaf of the bottom workbench's
|
||||
* split tree. The virtual tabs have unique ids (prefixed with the home session)
|
||||
* and carry the home scope in meta. Clicking a virtual tab sets
|
||||
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
|
||||
* so the pinned tab's content renders in-place (TerminalView connects to
|
||||
@@ -65,11 +65,11 @@ export function usePinnedTabs(input: {
|
||||
[pinnedEntries],
|
||||
)
|
||||
|
||||
/** The right panel's split tree with pinned virtual tabs injected into the
|
||||
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
|
||||
* overridden so the pinned tab's content is visible. */
|
||||
/** The bottom workbench's split tree with pinned virtual tabs injected
|
||||
* into the first leaf. When `activePinnedTabId` is set, that leaf's
|
||||
* `active` is overridden so the pinned tab's content is visible. */
|
||||
const augmentedTree = useMemo(
|
||||
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
|
||||
() => state === undefined ? undefined : injectPinnedIntoTree(state.bottomSplits, pinnedVirtualTabs, activePinnedTabId),
|
||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||
)
|
||||
|
||||
@@ -89,10 +89,8 @@ export function usePinnedTabs(input: {
|
||||
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
|
||||
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
|
||||
store.reduceFor(homeSessionId, s => {
|
||||
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
|
||||
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
|
||||
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
|
||||
return s
|
||||
const leaf = leafWithTab(s.bottomSplits, originalId)
|
||||
return leaf === undefined ? s : closeTab(s, leaf.id, originalId)
|
||||
})
|
||||
if (isAgentTabId(originalId)) {
|
||||
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
|
||||
@@ -131,10 +129,6 @@ export function usePinnedTabs(input: {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
actions.moveTabToEdge(payload, toPane, zone)
|
||||
},
|
||||
floatTab: (tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) return
|
||||
actions.floatTab(tabId)
|
||||
},
|
||||
pinTab: (tabId, scope) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
if (scope !== null) return
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
* shows only the thread's own conversation.
|
||||
*
|
||||
* Live streaming: `assistant/message` events only land when a step
|
||||
* completes, but `assistant/chunk` events stream token-level text and
|
||||
* reasoning deltas. The mapping accumulates both per block and supersedes
|
||||
* them with the assembled message once it lands (settled rows).
|
||||
* completes, but the host's `assistant/live-chunk` rows stream token-level
|
||||
* text and reasoning deltas (DSH 0.1.5 publishes them outside the session
|
||||
* log — see assistant-live.ts). The mapping accumulates both per block and
|
||||
* supersedes them with the assembled message once it lands (settled rows).
|
||||
*/
|
||||
import type { DiffHunk, ReadBlockLine } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
@@ -299,12 +300,13 @@ function resultCard(
|
||||
/**
|
||||
* Map a thread child's history rows onto compact transcript rows: the
|
||||
* inherited fork seed is cut at the last `session/end-seed`, context
|
||||
* injections map onto a collapsible injection row, `assistant/chunk`
|
||||
* injections map onto a collapsible injection row, `assistant/live-chunk`
|
||||
* deltas accumulate into streaming rows per (turn, step, block) and are
|
||||
* superseded by the assembled `assistant/message`, and tool invocations
|
||||
* render one expandable line each (arguments, paired result text, failure
|
||||
* marker; a still-executing call is marked until its result lands).
|
||||
* @param entries - history rows (event + host-computed view) in seq order.
|
||||
* @param entries - history rows (event + host-computed view) in seq order,
|
||||
* the live deltas of the in-flight attempt appended after the durable tail.
|
||||
* @returns display rows in log order.
|
||||
*/
|
||||
export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] {
|
||||
@@ -374,7 +376,7 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r
|
||||
rows.push({ kind: 'user', seq: event.seq, text })
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
case 'assistant/live-chunk': {
|
||||
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
|
||||
if (chunk === null || typeof chunk !== 'object') break
|
||||
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
|
||||
|
||||
@@ -29,8 +29,6 @@ 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
|
||||
/**
|
||||
* Pin/unpin a terminal tab (v0.17.0+). The shell snapshots the home cwd
|
||||
* at pin time; null clears the pin. Optional: when undefined the tab
|
||||
@@ -216,7 +214,6 @@ function LeafView(props: {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
}}
|
||||
onFloatTab={actions.floatTab}
|
||||
onPinTab={actions.pinTab}
|
||||
/>
|
||||
{leaf.tabs.length > 0 ? (
|
||||
@@ -319,7 +316,7 @@ export function Workbench(props: {
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
node={tree ?? state.splits}
|
||||
node={tree ?? state.bottomSplits}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
|
||||
+90
-555
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Per-session sidebar state: the panel geometry, the split-pane workbench
|
||||
* tree, open tabs, and the explorer expansion set. One state instance per
|
||||
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
|
||||
* a reload restores the exact layout of the session it belongs to — switching
|
||||
* Per-session sidebar state: the bottom workbench's split-pane tree, open
|
||||
* tabs, and the explorer expansion set. One state instance per conversation
|
||||
* id, persisted to localStorage under `dsh-sidebar:v1:<id>` so a reload
|
||||
* restores the exact layout of the session it belongs to — switching
|
||||
* conversations swaps the whole state (memory + isolation).
|
||||
*
|
||||
* The split tree is a recursive structure: a leaf holds a tab group, a split
|
||||
@@ -10,7 +10,6 @@
|
||||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||||
*/
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
|
||||
/**
|
||||
* Tab type identifier. Builtins register their ids (editor / git / terminal
|
||||
@@ -64,30 +63,9 @@ 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
|
||||
width: number
|
||||
/** The pane receiving newly opened tabs (last pane the user touched).
|
||||
* Pane ids are globally unique across BOTH trees (shared uid counter), so
|
||||
* one field resolves into either tree — see {@link treeOf}. */
|
||||
/** The pane receiving newly opened tabs (the last pane the user touched). */
|
||||
activePane: string | null
|
||||
/** Monotonic terminal tab counter (ids survive reloads). */
|
||||
nextTerminal: number
|
||||
@@ -101,9 +79,7 @@ export interface SidebarState {
|
||||
* unhighlighted.
|
||||
*/
|
||||
revealed: string[]
|
||||
/** The right sidebar's split tree (the original workbench). */
|
||||
splits: SplitNode
|
||||
/** Whether the bottom panel (a second, independent workbench) is open. */
|
||||
/** Whether the bottom panel (the plugin's one workbench) is open. */
|
||||
bottomOpen: boolean
|
||||
/** The bottom panel's height (clamped to the contract range). */
|
||||
bottomHeight: number
|
||||
@@ -113,11 +89,8 @@ export interface SidebarState {
|
||||
* on the bottomPanelAutoTerminal pref); later expansions never do.
|
||||
*/
|
||||
bottomOpenedOnce: boolean
|
||||
/** 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). */
|
||||
/** The bottom workbench's split tree. */
|
||||
bottomSplits: SplitNode
|
||||
/** Free windows (tabs dragged out onto the conversation area). */
|
||||
floats: FloatWindow[]
|
||||
/**
|
||||
* Live agent-terminal wait state (uuid → the wait the model currently
|
||||
* blocks on in `terminal_wait_for`), mirrored from the host's
|
||||
@@ -128,24 +101,14 @@ export interface SidebarState {
|
||||
agentWaits: Record<string, { needle: string; since: number }>
|
||||
}
|
||||
|
||||
export const PANEL_MIN = 280
|
||||
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}). */
|
||||
/** Bottom panel geometry contract (the upper 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: a phone-like portrait ratio
|
||||
* (390×780 ≈ 1:2). The creation path additionally caps the size to the
|
||||
* viewport (minus a 24px margin), so a short viewport gets a shorter —
|
||||
* not overflowing — window instead of an exact ratio. */
|
||||
export const FLOAT_DEFAULT_W = 390
|
||||
export const FLOAT_DEFAULT_H = 780
|
||||
/** The conversation column keeps at least this much height when the bottom
|
||||
* workbench claims space (see {@link setBottomHeight}). */
|
||||
export const CONVERSATION_MIN = 280
|
||||
|
||||
let nextIdCounter = 0
|
||||
/** Unique pane/tab id within one state instance. */
|
||||
@@ -163,7 +126,7 @@ export function mintTabId(): string {
|
||||
|
||||
/**
|
||||
* The largest numeric suffix across a raw persisted state's counter ids
|
||||
* (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and
|
||||
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
||||
* resets on every reload, so a split minted AFTER a reload would collide
|
||||
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
|
||||
* mapLeaf would then visit BOTH leaves and every open would land in both
|
||||
@@ -174,7 +137,7 @@ function maxCounterId(parsed: unknown): number {
|
||||
let max = 0
|
||||
const consider = (id: unknown): void => {
|
||||
if (typeof id !== 'string') return
|
||||
const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id)
|
||||
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
|
||||
if (match !== null) max = Math.max(max, Number(match[1]))
|
||||
}
|
||||
const walk = (node: unknown): void => {
|
||||
@@ -190,76 +153,32 @@ function maxCounterId(parsed: unknown): number {
|
||||
for (const child of record.children) walk(child)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** The default tab a fresh session seeds. */
|
||||
export type DefaultSeed = 'editor-home' | 'none'
|
||||
|
||||
/** A fresh default state: one seeded tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
|
||||
* (an editor tab with no path whose tree panel starts open,
|
||||
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
|
||||
* file explorer page — and 'none' starts with an empty pane (the store
|
||||
* passes it when the user disabled the editor tab type in settings). */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
if (seed === 'editor-home') {
|
||||
// No path: the editor host renders its empty-state hint and the docked
|
||||
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
}
|
||||
// The bottom panel starts closed with an empty pane (its welcome cards
|
||||
// offer the openable types on first use).
|
||||
/**
|
||||
* A fresh default state: one empty pane in the bottom workbench, closed.
|
||||
* (The right column belongs to DSH's native Sidebar, so this plugin's own
|
||||
* layout has nothing to seed — its welcome cards offer the openable types on
|
||||
* first expansion.)
|
||||
*/
|
||||
export function makeDefaultState(): SidebarState {
|
||||
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
return {
|
||||
panelOpen,
|
||||
width,
|
||||
activePane: leaf.id,
|
||||
activePane: bottomLeaf.id,
|
||||
nextTerminal: 1,
|
||||
nextBrowser: 1,
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
splits: leaf,
|
||||
bottomOpen: false,
|
||||
bottomHeight: BOTTOM_DEFAULT,
|
||||
bottomOpenedOnce: false,
|
||||
bottomSplits: bottomLeaf,
|
||||
floats: [],
|
||||
agentWaits: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tree node (or any descendant) carries the given pane/split id. */
|
||||
function treeHasId(node: SplitNode, id: string): boolean {
|
||||
if (node.id === id) return true
|
||||
if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
|
||||
return false
|
||||
}
|
||||
|
||||
/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the
|
||||
* bottom panel's tree, else 'splits' (the right panel's tree). Ids are
|
||||
* globally unique (the shared uid counter), so an id in neither tree falls
|
||||
* back to the right tree, where tree operations no-op on a missing node —
|
||||
* the pre-bottom-panel behavior. */
|
||||
export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' {
|
||||
return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits'
|
||||
}
|
||||
|
||||
/** Walk the tree and apply `visit` to the leaf with the given id. */
|
||||
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
|
||||
if (node.kind === 'leaf') {
|
||||
@@ -284,44 +203,6 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
|
||||
return firstLeaf(node.children[0]!)
|
||||
}
|
||||
|
||||
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
|
||||
function clearAllTabs(node: SplitNode): SplitNode {
|
||||
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
|
||||
return { ...node, children: node.children.map(clearAllTabs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
|
||||
* right sidebar — the "merged display" on mobile is the right panel alone,
|
||||
* whose tab strips now carry the bottom tree's tabs (depth-first order,
|
||||
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
|
||||
* structure stays — the desktop bottom panel re-renders its welcome cards)
|
||||
* and the panel closes. The active pane moves to the right tree's first
|
||||
* leaf so every new tab lands in the visible panel.
|
||||
*
|
||||
* Idempotent: a bottom tree with no tabs and a closed panel returns the
|
||||
* same reference. Runs when the viewport enters narrow (see the Sidebar
|
||||
* shell); migrating is permanent for the session — the tabs now live in the
|
||||
* right tree, exactly like the user "threw them in".
|
||||
*/
|
||||
export function migrateBottomTabs(state: SidebarState): SidebarState {
|
||||
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
|
||||
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
|
||||
const target = firstLeaf(state.splits)
|
||||
return {
|
||||
...state,
|
||||
activePane: target.id,
|
||||
bottomOpen: false,
|
||||
splits: bottomTabs.length > 0
|
||||
? mapLeaf(state.splits, target.id, leaf => {
|
||||
leaf.tabs = [...leaf.tabs, ...bottomTabs]
|
||||
})
|
||||
: state.splits,
|
||||
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the leaf containing a tab id, if any. */
|
||||
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
||||
if (node.kind === 'leaf') {
|
||||
@@ -340,22 +221,9 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
|
||||
return node.children.flatMap(allLeaves)
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
/** Whether a tab is open anywhere in the session's workbench. */
|
||||
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)
|
||||
return allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
}
|
||||
|
||||
/** Replace a leaf with a split of it plus a fresh empty leaf. */
|
||||
@@ -411,9 +279,6 @@ 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.
|
||||
*
|
||||
* The panes may live in DIFFERENT trees (dragging a tab between the two
|
||||
* panels): the tab then leaves its own tree and lands in the other one.
|
||||
*/
|
||||
export function moveTabToEdge(
|
||||
state: SidebarState,
|
||||
@@ -426,38 +291,7 @@ export function moveTabToEdge(
|
||||
// Dropped back onto its own pane's center: reorder to the end.
|
||||
return moveTab(state, fromPane, tabId, toPane, -1)
|
||||
}
|
||||
const key = treeOf(state, fromPane)
|
||||
const toKey = treeOf(state, toPane)
|
||||
if (key !== toKey) {
|
||||
// Cross-panel drop: remove the tab from its own tree, then merge (center)
|
||||
// or split (edge) a pane of the OTHER tree with the tab.
|
||||
const source = leafWithTab(state[key], tabId)
|
||||
if (source === undefined) return state
|
||||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||||
let emptied = false
|
||||
let sourceNode = 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) sourceNode = removeLeafAt(sourceNode, source.id)
|
||||
let targetNode = state[toKey]
|
||||
let activePane: string
|
||||
if (zone === 'center') {
|
||||
targetNode = mapLeaf(targetNode, toPane, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
})
|
||||
activePane = toPane
|
||||
} else {
|
||||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||||
const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||||
targetNode = result.node
|
||||
activePane = result.leafId
|
||||
}
|
||||
return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane }
|
||||
}
|
||||
const node = state[key]
|
||||
const node = state.bottomSplits
|
||||
const source = leafWithTab(node, tabId)
|
||||
if (source === undefined) return state
|
||||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||||
@@ -473,11 +307,11 @@ export function moveTabToEdge(
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
})
|
||||
return { ...state, [key]: splits, activePane: toPane }
|
||||
return { ...state, bottomSplits: splits, activePane: toPane }
|
||||
}
|
||||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||||
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||||
return { ...state, [key]: result.node, activePane: result.leafId }
|
||||
return { ...state, bottomSplits: result.node, activePane: result.leafId }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,7 +334,7 @@ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
|
||||
|
||||
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
|
||||
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||||
const key = treeOf(state, paneId)
|
||||
const key = 'bottomSplits'
|
||||
let emptied = false
|
||||
const splits = mapLeaf(state[key], paneId, (leaf) => {
|
||||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||||
@@ -512,7 +346,7 @@ export function closeTab(state: SidebarState, paneId: string, tabId: string): Si
|
||||
|
||||
/** Activate a tab in its pane (the pane's own tree). */
|
||||
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||||
const key = treeOf(state, paneId)
|
||||
const key = 'bottomSplits'
|
||||
return {
|
||||
...state,
|
||||
activePane: paneId,
|
||||
@@ -550,17 +384,14 @@ export function patchTab(
|
||||
const children = node.children.map(walk)
|
||||
return children === node.children ? node : { ...node, children }
|
||||
}
|
||||
const splits = walk(state.splits)
|
||||
const bottomSplits = walk(state.bottomSplits)
|
||||
const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
|
||||
return changed ? { ...state, splits, bottomSplits, floats } : state
|
||||
return changed ? { ...state, bottomSplits } : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is
|
||||
* structural metadata (NOT display fields like title/path), so it walks
|
||||
* both split trees AND the free windows exactly like {@link patchTab} —
|
||||
* the tab may live in either tree or float. Passing `null` clears the pin
|
||||
* the workbench's split tree exactly like {@link patchTab}. Passing `null` clears the pin
|
||||
* (the tab stays open in its home session); passing a `{ scope, homeCwd }`
|
||||
* object sets it. An unknown tab id is a strict no-op (same reference
|
||||
* returned) so a stale pin request never churns the state or rewrites
|
||||
@@ -615,54 +446,31 @@ export function setTabPin(
|
||||
if (children.every((child, i) => child === node.children[i])) return node
|
||||
return { ...node, children }
|
||||
}
|
||||
const splits = walk(state.splits)
|
||||
const bottomSplits = walk(state.bottomSplits)
|
||||
const floatIdx = state.floats.findIndex(f => f.tab.id === tabId)
|
||||
const floats = floatIdx < 0 ? state.floats : (() => {
|
||||
const oldFloat = state.floats[floatIdx]!
|
||||
const newTab = apply(oldFloat.tab)
|
||||
if (newTab === oldFloat.tab) return state.floats
|
||||
const next = state.floats.slice()
|
||||
next[floatIdx] = { ...oldFloat, tab: newTab }
|
||||
return next
|
||||
})()
|
||||
return changed ? { ...state, splits, bottomSplits, floats } : state
|
||||
return changed ? { ...state, bottomSplits } : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Land a tab in the active pane (or focus its existing instance by id).
|
||||
* Dedup strategies (single-instance, per-path, per-change) are owned by the
|
||||
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
|
||||
* this reducer only handles the id-based safety net (reconcile and
|
||||
* openDiffTab already check existence before calling) and the landing
|
||||
* itself — the service's dedupe path delegates here after its dedupeKey
|
||||
* check misses.
|
||||
*
|
||||
* The active pane may live in EITHER tree (pane ids are globally unique):
|
||||
* a stale id that survives in neither tree falls back to the right tree's
|
||||
* first pane instead of swallowing the open.
|
||||
* Land a tab in the workbench's first pane — the plugin's own opens (its
|
||||
* bottom-panel + menu, the auto-terminal, and every open when no native
|
||||
* surface is installed): the plugin owns no right column any more (DSH's
|
||||
* native sidebar is the right one), so the bottom workbench is the only tree.
|
||||
* @param state - the session state.
|
||||
* @param tab - the tab to land.
|
||||
* @returns the next state, with the bottom panel open.
|
||||
*/
|
||||
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
// A stale activePane (its pane was closed since) must not swallow the
|
||||
// open: fall back to the first pane of the right tree instead of dropping
|
||||
// the tab.
|
||||
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
const targetKey = treeOf(state, targetId)
|
||||
// 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))) {
|
||||
export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
const targetId = firstLeaf(state.bottomSplits).id
|
||||
// Id-based safety net: if a tab with the same id exists, focus it.
|
||||
for (const leaf of 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,
|
||||
bottomOpen: true,
|
||||
activePane: targetId,
|
||||
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
|
||||
bottomSplits: mapLeaf(state.bottomSplits, targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
@@ -673,8 +481,8 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb
|
||||
* The panes may live in DIFFERENT trees — dragging a tab between the two
|
||||
* panels removes it from its own tree and lands it in the other one. */
|
||||
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
|
||||
const fromKey = treeOf(state, fromPane)
|
||||
const toKey = treeOf(state, toPane)
|
||||
const fromKey = 'bottomSplits'
|
||||
const toKey = 'bottomSplits'
|
||||
if (fromKey !== toKey) {
|
||||
let moved: SidebarTab | undefined
|
||||
let emptied = false
|
||||
@@ -721,8 +529,8 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to
|
||||
|
||||
/** Split the active pane (or the pane containing the active tab). */
|
||||
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
|
||||
const paneId = state.activePane ?? firstLeaf(state.splits).id
|
||||
const key = treeOf(state, paneId)
|
||||
const paneId = state.activePane ?? firstLeaf(state.bottomSplits).id
|
||||
const key = 'bottomSplits'
|
||||
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
|
||||
}
|
||||
|
||||
@@ -741,14 +549,14 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
|
||||
* @returns the new state, with the diff pane active.
|
||||
*/
|
||||
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
|
||||
const existingLeaf = leafWithTab(state.splits, tab.id)
|
||||
const existingLeaf = leafWithTab(state.bottomSplits, tab.id)
|
||||
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
|
||||
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
|
||||
const diffLeaf = allLeaves(state.bottomSplits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
|
||||
if (diffLeaf !== undefined) {
|
||||
return {
|
||||
...state,
|
||||
activePane: diffLeaf.id,
|
||||
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
|
||||
bottomSplits: mapLeaf(state.bottomSplits, diffLeaf.id, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
@@ -757,37 +565,25 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side
|
||||
// First diff: split the source pane, the diff tab in the new LOWER leaf.
|
||||
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
|
||||
// regular open path instead of dropping the tab into an orphaned leaf.)
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
|
||||
return openTabInActivePane(state, tab)
|
||||
if (!allLeaves(state.bottomSplits).some(leaf => leaf.id === sourcePaneId)) {
|
||||
return openTabInBottomPane(state, tab)
|
||||
}
|
||||
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
|
||||
return { ...state, splits: result.node, activePane: result.leafId }
|
||||
const result = insertLeafAt(state.bottomSplits, sourcePaneId, 'col', tab, false)
|
||||
return { ...state, bottomSplits: result.node, activePane: result.leafId }
|
||||
}
|
||||
|
||||
/** Toggle the panel open/closed (opening restores the previous layout). */
|
||||
export function togglePanel(state: SidebarState): SidebarState {
|
||||
return { ...state, panelOpen: !state.panelOpen }
|
||||
}
|
||||
|
||||
/** Toggle the bottom panel open/closed (independent of the right panel). */
|
||||
/** Expand/collapse the bottom workbench. */
|
||||
export function toggleBottomPanel(state: SidebarState): SidebarState {
|
||||
return { ...state, bottomOpen: !state.bottomOpen }
|
||||
}
|
||||
|
||||
/** Set the panel width (clamped to the contract range; the upper bound is
|
||||
* the viewport so the fullscreen expansion can fill the window). */
|
||||
export function setWidth(state: SidebarState, width: number): SidebarState {
|
||||
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
|
||||
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
|
||||
}
|
||||
|
||||
/** Set the bottom panel height (clamped to the contract range). The upper
|
||||
* bound leaves the center column (the agent output area) at least PANEL_MIN
|
||||
* tall — without the cap the bottom panel could swallow the whole viewport
|
||||
* and squeeze the conversation to zero height. */
|
||||
/** Set the bottom workbench height (clamped to the contract range). The
|
||||
* upper bound leaves the conversation column at least {@link CONVERSATION_MIN}
|
||||
* tall — without the cap the workbench could swallow the whole viewport and
|
||||
* squeeze the conversation to zero height. */
|
||||
export function setBottomHeight(state: SidebarState, height: number): SidebarState {
|
||||
const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity
|
||||
const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN)
|
||||
const max = Math.max(BOTTOM_MIN, viewport - CONVERSATION_MIN)
|
||||
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
|
||||
}
|
||||
|
||||
@@ -852,147 +648,10 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
|
||||
/** State-level {@link resizeSplit} route: the divider may live in either
|
||||
* tree (split ids are globally unique). */
|
||||
export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
|
||||
const key = treeOf(state, splitId)
|
||||
const key = 'bottomSplits'
|
||||
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)
|
||||
// Phone-ratio default, capped to the viewport before centering so the
|
||||
// clamped position never leaves the window's bottom past the fold.
|
||||
const vw = viewportW()
|
||||
const vh = viewportH()
|
||||
const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24))
|
||||
const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24))
|
||||
const window = clampFloatGeometry(
|
||||
x - width / 2,
|
||||
y - height / 2,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
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:'
|
||||
|
||||
@@ -1044,8 +703,7 @@ export function reconcileAgentTerminals(
|
||||
state: SidebarState,
|
||||
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
|
||||
): SidebarState {
|
||||
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
.concat(state.floats.map(float => float.tab))
|
||||
const existingTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
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))
|
||||
@@ -1069,29 +727,25 @@ export function reconcileAgentTerminals(
|
||||
}
|
||||
if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) 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;
|
||||
// a FLOATED agent terminal leaves with its window.
|
||||
let splits = state.splits
|
||||
let floats = state.floats
|
||||
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
|
||||
let bottomSplits = state.bottomSplits
|
||||
for (const tab of toRemove) {
|
||||
const leaf = leafWithTab(splits, tab.id)
|
||||
const leaf = leafWithTab(bottomSplits, 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)
|
||||
bottomSplits = closeTab({ ...state, bottomSplits }, leaf.id, tab.id).bottomSplits
|
||||
}
|
||||
}
|
||||
// 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, floats }
|
||||
// workbench's landing pane via openTabInBottomPane; the next reconcile is a
|
||||
// no-op for them.
|
||||
let next: SidebarState = { ...state, bottomSplits }
|
||||
for (const terminal of toAdd) {
|
||||
const tab: SidebarTab = {
|
||||
id: agentTabId(terminal.uuid),
|
||||
type: 'terminal',
|
||||
title: terminal.title,
|
||||
}
|
||||
next = openTabInActivePane(next, tab)
|
||||
next = openTabInBottomPane(next, tab)
|
||||
}
|
||||
return { ...next, agentWaits: serverWaits }
|
||||
}
|
||||
@@ -1107,37 +761,6 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
|
||||
* cache-hit session switches, so a drag in one conversation carries to all
|
||||
* the others (last drag wins).
|
||||
*/
|
||||
const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width'
|
||||
|
||||
/** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */
|
||||
function clampWidth(width: number): number {
|
||||
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
|
||||
return Math.min(max, Math.max(PANEL_MIN, Math.round(width)))
|
||||
}
|
||||
|
||||
/** Read the cross-session panel width (undefined when never dragged). */
|
||||
function readGlobalWidth(): number | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(GLOBAL_WIDTH_KEY)
|
||||
if (raw !== null) {
|
||||
const parsed = Number(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Storage unavailable: fall back to the per-session behavior.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Persist the cross-session panel width (best-effort, like the session states). */
|
||||
function writeGlobalWidth(width: number): void {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_WIDTH_KEY, String(width))
|
||||
} catch {
|
||||
// Storage full or unavailable: layout memory is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable snapshot handed to React (replaced only on real changes). */
|
||||
export interface SidebarSnapshot {
|
||||
sessionId: string | undefined
|
||||
@@ -1150,13 +773,6 @@ export interface SidebarSnapshot {
|
||||
prefs: SidebarPrefs
|
||||
}
|
||||
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
* clamped to the panel floor (a tiny percent must stay usable) and to the
|
||||
* viewport (a large one must never cover the whole window). */
|
||||
export function defaultWidthFor(viewport: number, percent: number): number {
|
||||
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
|
||||
}
|
||||
|
||||
/**
|
||||
* URL escape hatch (#369): loading the app with `?dsh-sidebar-reset` drops
|
||||
* the persisted layout for the session instead of restoring it. When a
|
||||
@@ -1176,21 +792,15 @@ function resetRequested(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
function loadState(sessionId: string): SidebarState {
|
||||
const reset = resetRequested()
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
if (reset) {
|
||||
try {
|
||||
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
localStorage.removeItem(GLOBAL_WIDTH_KEY)
|
||||
} catch {
|
||||
// Storage unavailable: the default layout below is still the escape.
|
||||
}
|
||||
}
|
||||
// The panel width is a cross-session preference: the last dragged width
|
||||
// wins over a session's own persisted value, so switching conversations
|
||||
// keeps the width the user chose anywhere.
|
||||
const globalWidth = reset ? undefined : readGlobalWidth()
|
||||
if (!reset) {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
@@ -1200,38 +810,13 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
// sanitize re-ids any duplicates the pre-seeding counter left behind.
|
||||
nextIdCounter = maxCounterId(parsed)
|
||||
const sanitized = sanitizeState(parsed)
|
||||
if (sanitized !== undefined) {
|
||||
const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
|
||||
// A narrow panel is a full-screen drawer, so every page/session load
|
||||
// starts it closed even when the prior desktop/mobile state persisted
|
||||
// `panelOpen: true`. Preserve the entire restored workbench (panes,
|
||||
// tabs, bottom panel, and free windows); visibility is the sole
|
||||
// viewport-derived override.
|
||||
return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
|
||||
? { ...restored, panelOpen: false }
|
||||
: restored
|
||||
}
|
||||
if (sanitized !== undefined) return sanitized
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unavailable storage: fall through to the default.
|
||||
}
|
||||
}
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), the panel
|
||||
// starts open only when the preference says so, and the seed tab is the
|
||||
// empty files window (tree panel open) in BOTH editorExplorer modes — a
|
||||
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
|
||||
// session starts collapsed instead — the panel is a full-screen drawer
|
||||
// there, and auto-opening it on first paint would cover the conversation
|
||||
// before the user asked. Persisted layouts follow the same narrow-load
|
||||
// visibility rule above, while their workbench contents remain intact.
|
||||
const width = globalWidth ?? (viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent))
|
||||
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
|
||||
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
|
||||
return makeDefaultState(width, openByDefault, seed)
|
||||
return makeDefaultState()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1245,8 +830,6 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
if (parsed === null || typeof parsed !== 'object') return undefined
|
||||
const record = parsed as Record<string, unknown>
|
||||
if (typeof record.panelOpen !== 'boolean') return undefined
|
||||
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
|
||||
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
|
||||
return undefined
|
||||
}
|
||||
@@ -1258,67 +841,36 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
: 1
|
||||
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
||||
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
||||
// The seen/reid maps are SHARED across both trees: pane/split ids must be
|
||||
// globally unique (the runtime uid counter is shared too), so a duplicate
|
||||
// seen first in the right tree gets a fresh id when it reappears in the
|
||||
// bottom tree.
|
||||
// Pane/split ids must be globally unique (the runtime uid counter is
|
||||
// shared), so a duplicate id gets a fresh one.
|
||||
const seen = new Set<string>()
|
||||
const reid = new Map<string, string>()
|
||||
const restoredSplits = sanitizeNode(record.splits, seen, reid)
|
||||
if (restoredSplits === undefined) return undefined
|
||||
const splits = pruneEmptyPanes(restoredSplits)
|
||||
// Bottom-panel fields arrived in a later build: a missing or malformed
|
||||
// value on an OLDER persisted state defaults (closed / default height /
|
||||
// empty pane) so existing layouts keep loading, like nextBrowser.
|
||||
// Workbench fields arrived in a later build: a missing or malformed value
|
||||
// on an OLDER persisted state defaults (closed / default height / empty
|
||||
// pane) so existing layouts keep loading, like nextBrowser.
|
||||
const bottomOpen = record.bottomOpen === true
|
||||
// Cap the persisted height so the center column (the agent output area)
|
||||
// keeps at least PANEL_MIN tall (a stale full-height bottom panel from an
|
||||
// older build must never squeeze the conversation to zero).
|
||||
// Cap the persisted height so the conversation column (the agent output
|
||||
// area) keeps a usable minimum (a stale full-height panel from an older
|
||||
// build must never squeeze the conversation to zero). The cap is
|
||||
// setBottomHeight's own contract, or a restored height would snap on the
|
||||
// first drag.
|
||||
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
|
||||
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
|
||||
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - CONVERSATION_MIN)
|
||||
const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight)
|
||||
? record.bottomHeight
|
||||
: BOTTOM_DEFAULT
|
||||
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
|
||||
const activePane = requestedActivePane === null
|
||||
? null
|
||||
: treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
|
||||
: allLeaves(bottomSplits).some(leaf => leaf.id === requestedActivePane)
|
||||
? requestedActivePane
|
||||
: firstLeaf(splits).id
|
||||
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
||||
: firstLeaf(bottomSplits).id
|
||||
return {
|
||||
panelOpen: record.panelOpen,
|
||||
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
|
||||
// A stale duplicate pane id may have been re-ided; follow the rename so
|
||||
// new tabs still land in the pane the user was using.
|
||||
activePane,
|
||||
@@ -1326,7 +878,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
nextBrowser,
|
||||
expanded: record.expanded as string[],
|
||||
revealed: [],
|
||||
splits,
|
||||
bottomOpen,
|
||||
bottomHeight,
|
||||
// An older persisted state never expanded the bottom panel (the field
|
||||
@@ -1334,7 +885,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
// auto-terminal exactly once after the upgrade.
|
||||
bottomOpenedOnce: record.bottomOpenedOnce === true,
|
||||
bottomSplits,
|
||||
floats,
|
||||
// The agent wait state is TRANSIENT (like revealed): never restored from
|
||||
// storage — the host's first push after attach repopulates it.
|
||||
agentWaits: {},
|
||||
@@ -1528,20 +1078,13 @@ export class SidebarStore {
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId, this.prefs)
|
||||
state = loadState(sessionId)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Cache hit: another session's load/ops may have left the uid
|
||||
// counter below THIS session's persisted ids — re-seed so fresh
|
||||
// pane/split ids can never collide with its tree.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
// The panel width is cross-session: adopt the latest dragged width
|
||||
// (a cached session keeps its own layout otherwise).
|
||||
const globalWidth = readGlobalWidth()
|
||||
if (globalWidth !== undefined && state.width !== globalWidth) {
|
||||
state = { ...state, width: globalWidth }
|
||||
this.bySession.set(sessionId, state)
|
||||
}
|
||||
}
|
||||
this.snapshot = { sessionId, state, prefs: this.prefs }
|
||||
}
|
||||
@@ -1632,7 +1175,7 @@ export class SidebarStore {
|
||||
const counterBefore = nextIdCounter
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId, this.prefs)
|
||||
state = loadState(sessionId)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Re-seed the uid counter past THIS session's persisted ids, exactly
|
||||
@@ -1649,14 +1192,6 @@ export class SidebarStore {
|
||||
}
|
||||
|
||||
private schedulePersist(sessionId: string, state: SidebarState): void {
|
||||
// Keep the cross-session width in sync: any width change (drag, fullscreen
|
||||
// toggle) on the ACTIVE session becomes the shared width for every
|
||||
// conversation. A targeted open persists an INACTIVE session (reduceFor)
|
||||
// and must not clobber that global — its width is stale by definition,
|
||||
// so writing it would break "last drag wins".
|
||||
if (sessionId === this.snapshot.sessionId) {
|
||||
writeGlobalWidth(state.width)
|
||||
}
|
||||
// Per-session debounce timers: one session's pending write must never
|
||||
// cancel another's (targeted opens schedule writes for INACTIVE
|
||||
// sessions while the active session may already have one pending —
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Open-tab reconciliation after a file-tree mutation (rename/delete).
|
||||
*
|
||||
* The tree owns the rows; the TABS live in the sidebar state (either split
|
||||
* tree, the bottom panel, or free windows). A rename must retarget every
|
||||
* The tree owns the rows; the TABS live in the sidebar state (the bottom
|
||||
* workbench's split tree). A rename must retarget every
|
||||
* tab whose `path` is the renamed file (the editor content survives and
|
||||
* later saves land on the new path); a delete must close every tab at or
|
||||
* under the removed path — files and anything inside a removed directory
|
||||
@@ -16,15 +16,12 @@ import { baseName } from './FileTree.tsx'
|
||||
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import { isWithinWorkspace } from './paths.ts'
|
||||
|
||||
/** Every open tab that carries a file path (either split tree, the bottom
|
||||
* panel, and free windows — a floating tab is as open as a docked one). */
|
||||
/** Every open tab that carries a file path. */
|
||||
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
|
||||
const state = snapshot.state
|
||||
if (state === undefined) return []
|
||||
const tabs: SidebarTab[] = []
|
||||
for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
|
||||
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
|
||||
for (const float of state.floats) tabs.push(float.tab)
|
||||
return tabs.filter(tab => tab.path !== undefined)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user