feat: pinned rail component + cross-session focus
M4 of pinned terminals (v0.17.0): - New PinnedRail component: compact strip above the tab bar, renders pinned terminals from other sessions (pin icon + terminal icon + title + tooltip with kind/scope/cwd) - Click navigates to the home session (ctx.sessions.open) and activates the tab (or raises its float) via the pinnedJumpRef effect - Right-click menu: Unpin / Close Terminal; middle-click close - IconPinOutline16 added to icons.tsx - Rail styles in sidebar.module.css (token-driven, no-drag opt-out) - Sidebar: collectPinnedTabs on store snapshot + session list, railRevision state forces re-render after reduceFor (which doesn't notify) - Cross-session close: reduceFor(closeTab/closeFloatByTab) + ptyClose/ agentPtyClose; cross-session unpin: reduceFor(setTabPin null)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The PinnedRail (v0.17.0+): a compact strip above the tab bar showing
|
||||
* terminal tabs pinned in OTHER sessions that should surface in the current
|
||||
* session (global pins, or workspace pins whose homeCwd matches the viewer's
|
||||
* cwd). The rail is the only cross-session surface — pinned tabs themselves
|
||||
* live in their home session's state (the authoritative copy), and the rail
|
||||
* just renders a clickable affordance to jump back to them.
|
||||
*
|
||||
* Click navigates to the home session and activates the tab (or raises its
|
||||
* free window). Right-click offers unpin / close; middle-click closes. The
|
||||
* rail hides itself when no pinned tabs are visible.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PinnedTabEntry } from './pinned.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
import { IconPinOutline16, IconTerminalOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The kind label for the tooltip (UI vs Agent terminal). */
|
||||
function kindLabel(tabId: string): string {
|
||||
return isAgentTabId(tabId) ? t('pinnedTerminalKindAgent') : t('pinnedTerminalKindUi')
|
||||
}
|
||||
|
||||
/** The scope label for the tooltip. */
|
||||
function scopeLabel(scope: 'workspace' | 'global'): string {
|
||||
return scope === 'global' ? t('pinnedTerminalScopeGlobal') : t('pinnedTerminalScopeWorkspace')
|
||||
}
|
||||
|
||||
/** Build the tooltip string: "{kind} · {scope} · {cwd}". */
|
||||
function tooltipOf(entry: PinnedTabEntry): string {
|
||||
const pin = entry.tab.pin
|
||||
if (pin === undefined) return entry.tab.title
|
||||
const cwd = pin.homeCwd ?? '—'
|
||||
return t('pinnedTerminalTooltip', {
|
||||
kind: kindLabel(entry.tab.id),
|
||||
scope: scopeLabel(pin.scope),
|
||||
cwd,
|
||||
})
|
||||
}
|
||||
|
||||
export function PinnedRail(props: {
|
||||
entries: readonly PinnedTabEntry[]
|
||||
onFocus: (homeSessionId: string, tabId: string) => void
|
||||
onUnpin: (homeSessionId: string, tabId: string) => void
|
||||
onClose: (homeSessionId: string, tabId: string) => void
|
||||
}) {
|
||||
const { entries, onFocus, onUnpin, onClose } = props
|
||||
const [menu, setMenu] = useState<{ homeSessionId: string; tabId: string; x: number; y: number } | null>(null)
|
||||
|
||||
// Middle-click close (mirrors TabBar's middlePressed pattern): the press
|
||||
// target is recorded on middle mousedown (preventDefaulted to disarm
|
||||
// Chrome's autoscroll) and the close settles on the first middle mouseup
|
||||
// OVER that same entry. Release-position semantics match VS Code.
|
||||
const middlePressed = useRef<{ homeSessionId: string; tabId: string; node: HTMLElement } | null>(null)
|
||||
const closeRef = useRef(onClose)
|
||||
useEffect(() => { closeRef.current = onClose })
|
||||
useEffect(() => {
|
||||
const onMouseUp = (event: MouseEvent): void => {
|
||||
if (event.button !== 1) return
|
||||
const pressed = middlePressed.current
|
||||
middlePressed.current = null
|
||||
if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
|
||||
closeRef.current(pressed.homeSessionId, pressed.tabId)
|
||||
}
|
||||
}
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
return () => { window.removeEventListener('mouseup', onMouseUp) }
|
||||
}, [])
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={css.pinnedRail} data-dsh-pinned-rail>
|
||||
<span className={css.pinnedRailLabel}>{t('pinnedRailLabel')}</span>
|
||||
{entries.map(entry => (
|
||||
<div
|
||||
key={`${entry.homeSessionId}:${entry.tab.id}`}
|
||||
className={css.pinnedTab}
|
||||
data-dsh-pinned-tab={entry.tab.id}
|
||||
title={tooltipOf(entry)}
|
||||
onClick={() => { onFocus(entry.homeSessionId, entry.tab.id) }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
middlePressed.current = { homeSessionId: entry.homeSessionId, tabId: entry.tab.id, node: event.currentTarget }
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
setMenu({ homeSessionId: entry.homeSessionId, tabId: entry.tab.id, x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
>
|
||||
<IconPinOutline16 size={12} />
|
||||
<IconTerminalOutline16 size={14} />
|
||||
<span className={css.pinnedTabTitle}>{entry.tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pinnedTabClose}
|
||||
aria-label={t('closePinnedTerminal')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose(entry.homeSessionId, entry.tab.id)
|
||||
}}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Menu
|
||||
open={menu !== null}
|
||||
onClose={() => { setMenu(null) }}
|
||||
items={[
|
||||
{ id: 'unpin', label: t('unpinTerminal') },
|
||||
{ id: 'close', label: t('closePinnedTerminal') },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = menu
|
||||
if (target === null) return
|
||||
setMenu(null)
|
||||
if (id === 'unpin') onUnpin(target.homeSessionId, target.tabId)
|
||||
else if (id === 'close') onClose(target.homeSessionId, target.tabId)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+100
-2
@@ -35,11 +35,13 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarSessionList } from '../context-types.ts'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
|
||||
BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
||||
floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, raiseFloat, reconcileAgentTerminals,
|
||||
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
} from './state.ts'
|
||||
import { collectPinnedTabs, type PinnedTabEntry } from './pinned.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
||||
@@ -51,6 +53,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { FreeWindow } from './FreeWindow.tsx'
|
||||
import { PinnedRail } from './PinnedRail.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
@@ -626,6 +629,53 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Pinned-terminal jump-back (v0.17.0+): clicking a rail entry from another
|
||||
* session navigates to the home session and activates the pinned tab (or
|
||||
* raises its free window). The ref records the target {homeSessionId,
|
||||
* tabId}; once the current session becomes the home session, the effect
|
||||
* fires: expand the panel if collapsed, then activate the tab (docked) or
|
||||
* raise its float. Mirrors the subagentJumpRef pattern above.
|
||||
*
|
||||
* The railRevision state forces the entries useMemo to recompute after a
|
||||
* rail action (unpin/close) that goes through reduceFor — which doesn't
|
||||
* notify (targeted opens must not re-render the active session's UI). The
|
||||
* revision bump is the only signal the rail has that another session's
|
||||
* state changed.
|
||||
*/
|
||||
const pinnedJumpRef = useRef<{ homeSessionId: string; tabId: string } | undefined>(undefined)
|
||||
const [railRevision, setRailRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const pending = pinnedJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending.homeSessionId) return
|
||||
pinnedJumpRef.current = undefined
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => {
|
||||
// Docked: find in either tree and activate.
|
||||
for (const leaf of allLeaves(s.splits).concat(allLeaves(s.bottomSplits))) {
|
||||
if (leaf.tabs.some(tab => tab.id === pending.tabId)) {
|
||||
return activateTab(s, leaf.id, pending.tabId)
|
||||
}
|
||||
}
|
||||
// Floated: raise the window.
|
||||
const floated = floatWithTab(s, pending.tabId)
|
||||
if (floated !== undefined) return raiseFloat(s, floated.id)
|
||||
return s
|
||||
})
|
||||
}, [sessionId, store])
|
||||
|
||||
/**
|
||||
* Cross-session pinned-tab collection for the rail. Recomputed on every
|
||||
* store notify (active session state change), session-list change (cwd
|
||||
* hydration), and rail action (the revision bump covers reduceFor updates
|
||||
* that don't notify). The rail only shows tabs from OTHER sessions — the
|
||||
* viewer's own pinned tabs are already on its tab strip.
|
||||
*/
|
||||
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
|
||||
if (sessionId === undefined) return []
|
||||
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
|
||||
}, [store, sessionId, cwd, snapshot, railRevision])
|
||||
|
||||
// The app shell's center column: the bottom panel spans ONLY that 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
|
||||
@@ -1212,6 +1262,48 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
},
|
||||
}), [store, sessionId, cwd])
|
||||
|
||||
/**
|
||||
* Pinned-rail actions (v0.17.0+): unpin and close target the HOME session
|
||||
* (which is NOT the current session — the rail only shows other sessions'
|
||||
* pinned tabs). reduceFor updates the home session's state without
|
||||
* notifying (targeted opens must not re-render the active session); the
|
||||
* railRevision bump is the local signal that makes the entries useMemo
|
||||
* recompute and the rail re-render.
|
||||
*/
|
||||
const onUnpinPinned = useCallback((homeSessionId: string, tabId: string): void => {
|
||||
store.reduceFor(homeSessionId, s => setTabPin(s, tabId, null))
|
||||
setRailRevision(v => v + 1)
|
||||
}, [store])
|
||||
|
||||
const onClosePinned = useCallback((homeSessionId: string, tabId: string): void => {
|
||||
store.reduceFor(homeSessionId, s => {
|
||||
// Docked: find in either tree and close (empties pane if last tab).
|
||||
const leaf = leafWithTab(s.splits, tabId) ?? leafWithTab(s.bottomSplits, tabId)
|
||||
if (leaf !== undefined) return closeTab(s, leaf.id, tabId)
|
||||
// Floated: close the window (the tab leaves with it).
|
||||
if (s.floats.some(f => f.tab.id === tabId)) return closeFloatByTab(s, tabId)
|
||||
return s
|
||||
})
|
||||
// Kill the PTY (the tab is gone; the WS close frame may not reach the
|
||||
// host if the terminal was in a non-active session).
|
||||
if (isAgentTabId(tabId)) {
|
||||
void api.agentPtyClose(agentUuidOf(tabId)).catch(() => { /* already released */ })
|
||||
} else {
|
||||
// The home cwd is the pin's homeCwd (snapshot at pin time); fall back
|
||||
// to undefined for a global pin without homeCwd.
|
||||
void api.ptyClose({ sessionId: homeSessionId }, tabId).catch(() => { /* already released */ })
|
||||
}
|
||||
setRailRevision(v => v + 1)
|
||||
}, [store])
|
||||
|
||||
const onFocusPinned = useCallback((homeSessionId: string, tabId: string): void => {
|
||||
// Navigate to the home session (the sessions service switches the host
|
||||
// current session; Sidebar's setSession effect follows). Record the
|
||||
// jump target so the effect above can activate the tab once we land.
|
||||
pinnedJumpRef.current = { homeSessionId, tabId }
|
||||
ctx.sessions.open?.(homeSessionId)
|
||||
}, [ctx])
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button: append `@<relative path>` to the
|
||||
* session's composer draft (space-separated). The conversation service is
|
||||
@@ -1442,6 +1534,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
/>
|
||||
)}
|
||||
<div className={css.panelBody}>
|
||||
<PinnedRail
|
||||
entries={pinnedEntries}
|
||||
onFocus={onFocusPinned}
|
||||
onUnpin={onUnpinPinned}
|
||||
onClose={onClosePinned}
|
||||
/>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
|
||||
@@ -70,6 +70,17 @@ export const IconUploadOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Pin glyph in the app's outline style (1.5px stroke, currentColor): a pushpin
|
||||
* tilted to the lower-right. Used by the PinnedRail and the tab context menu's
|
||||
* pin entry (v0.17.0+).
|
||||
*/
|
||||
export const IconPinOutline16 = ({ 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">
|
||||
<path d="M9.5 1.5 14.5 6.5 12.5 8.5 10 6 5.5 10.5 6 12 4.5 13.5 2.5 11.5 4 10 5.5 10.5 10 6 7.5 8.5 6.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
|
||||
@@ -458,6 +458,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tabBar,
|
||||
.pinnedRail,
|
||||
.floatHeader {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
@@ -865,6 +866,87 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Pinned-terminal rail (v0.17.0+) ──────────────────────────────────── */
|
||||
|
||||
/* A compact strip above the tab bar showing pinned terminals from OTHER
|
||||
sessions. Only renders when there is at least one visible pinned tab.
|
||||
Hairline-separated from the tab bar below; token-driven so skins follow. */
|
||||
.pinnedRail {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0 4px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.pinnedRail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pinnedRailLabel {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 6px 0 4px;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinnedTab {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 180px;
|
||||
padding: 0 6px;
|
||||
height: 28px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
border-right: 1px solid var(--dsw-alias-border-l1);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.pinnedTab:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pinnedTabTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinnedTabClose {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.pinnedTabClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Explorer ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.explorer {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* PinnedRail component tests (v0.17.0+). The rail is a pure presentation
|
||||
* component: it renders the entries passed to it and fires callbacks. The
|
||||
* cross-session resolution (collectPinnedTabs) and the jump-back effect are
|
||||
* tested elsewhere (pinned.spec.ts, state.spec.ts). These tests cover the
|
||||
* rail's render conditions, tooltip, click/focus, right-click menu, and
|
||||
* middle-click close.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import { PinnedRail } from '../src/client/PinnedRail.tsx'
|
||||
import type { PinnedTabEntry } from '../src/client/pinned.ts'
|
||||
|
||||
/** Point the browser-language fallback at Chinese so the labels assert. */
|
||||
function stubZh(): void {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { language: 'zh-CN' },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<PinnedTabEntry> & { tabId?: string } = {}): PinnedTabEntry {
|
||||
const tabId = overrides.tabId ?? 'terminal:1'
|
||||
return {
|
||||
tab: {
|
||||
id: tabId,
|
||||
type: 'terminal',
|
||||
title: 'My Terminal',
|
||||
pin: { scope: 'global' },
|
||||
...(overrides.tab?.pin ? { pin: overrides.tab.pin } : {}),
|
||||
},
|
||||
homeSessionId: 'home-session',
|
||||
...overrides,
|
||||
} as PinnedTabEntry
|
||||
}
|
||||
|
||||
function mountRail(entries: readonly PinnedTabEntry[]): {
|
||||
rail: HTMLElement | null
|
||||
entryEls: HTMLElement[]
|
||||
onFocus: ReturnType<typeof vi.fn>
|
||||
onUnpin: ReturnType<typeof vi.fn>
|
||||
onClose: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const onFocus = vi.fn()
|
||||
const onUnpin = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const root: Root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(PinnedRail, { entries, onFocus, onUnpin, onClose }))
|
||||
})
|
||||
const rail = container.querySelector<HTMLElement>('[data-dsh-pinned-rail]')
|
||||
const entryEls = [...container.querySelectorAll<HTMLElement>('[data-dsh-pinned-tab]')]
|
||||
return { rail, entryEls, onFocus, onUnpin, onClose, unmount: () => { act(() => { root.unmount() }); container.remove() } }
|
||||
}
|
||||
|
||||
/** The portaled menu rows (empty when the menu is closed). */
|
||||
function menuItems(): HTMLElement[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('PinnedRail', () => {
|
||||
it('renders nothing when there are no entries', () => {
|
||||
stubZh()
|
||||
const { rail } = mountRail([])
|
||||
expect(rail).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the label and one entry per pinned tab with a tooltip', () => {
|
||||
stubZh()
|
||||
const entries = [
|
||||
makeEntry({ tabId: 'terminal:1' }),
|
||||
makeEntry({ tabId: 'terminal:2', tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'workspace', homeCwd: '/proj' } } }),
|
||||
]
|
||||
const { rail, entryEls } = mountRail(entries)
|
||||
expect(rail).not.toBeNull()
|
||||
expect(entryEls).toHaveLength(2)
|
||||
// The label is present.
|
||||
expect(rail!.textContent).toContain('固定终端')
|
||||
// The tooltip carries the kind/scope/cwd info.
|
||||
expect(entryEls[0]!.title).toContain('UI 终端')
|
||||
expect(entryEls[0]!.title).toContain('固定到全局')
|
||||
expect(entryEls[1]!.title).toContain('固定到工作区')
|
||||
expect(entryEls[1]!.title).toContain('/proj')
|
||||
})
|
||||
|
||||
it('uses the Agent label for agent terminal tabs', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'agent:abc-123', tab: { id: 'agent:abc-123', type: 'terminal', title: 'Agent T', pin: { scope: 'global' } } })]
|
||||
const { entryEls } = mountRail(entries)
|
||||
expect(entryEls[0]!.title).toContain('Agent 终端')
|
||||
})
|
||||
|
||||
it('click fires onFocus with the home session id and tab id', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })]
|
||||
const { entryEls, onFocus, unmount } = mountRail(entries)
|
||||
try {
|
||||
act(() => { entryEls[0]!.click() })
|
||||
expect(onFocus).toHaveBeenCalledTimes(1)
|
||||
expect(onFocus).toHaveBeenCalledWith('home-A', 'terminal:1')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('right-click opens a menu with Unpin and Close, and clicking Unpin fires onUnpin', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })]
|
||||
const { entryEls, onUnpin, unmount } = mountRail(entries)
|
||||
try {
|
||||
act(() => {
|
||||
entryEls[0]!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 30 }))
|
||||
})
|
||||
expect(menuItems().map(item => item.textContent)).toEqual(['取消固定', '关闭终端'])
|
||||
act(() => { menuItems()[0]!.click() })
|
||||
expect(onUnpin).toHaveBeenCalledWith('home-A', 'terminal:1')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('right-click Close fires onClose with the home session id and tab id', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })]
|
||||
const { entryEls, onClose, unmount } = mountRail(entries)
|
||||
try {
|
||||
act(() => {
|
||||
entryEls[0]!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 30 }))
|
||||
})
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
expect(onClose).toHaveBeenCalledWith('home-A', 'terminal:1')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('the per-entry X button fires onClose and stops propagation (no focus)', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })]
|
||||
const { entryEls, onFocus, onClose, unmount } = mountRail(entries)
|
||||
try {
|
||||
const closeBtn = entryEls[0]!.querySelector<HTMLButtonElement>('[class*="pinnedTabClose"]')!
|
||||
act(() => { closeBtn.click() })
|
||||
expect(onClose).toHaveBeenCalledWith('home-A', 'terminal:1')
|
||||
expect(onFocus).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('middle-click close fires onClose (release on the same entry)', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })]
|
||||
const { entryEls, onClose, unmount } = mountRail(entries)
|
||||
try {
|
||||
act(() => {
|
||||
entryEls[0]!.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true, cancelable: true }))
|
||||
})
|
||||
act(() => {
|
||||
// mouseup must land on the pressed entry's node.
|
||||
entryEls[0]!.dispatchEvent(new MouseEvent('mouseup', { button: 1, bubbles: true }))
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledWith('home-A', 'terminal:1')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('middle-click press then release elsewhere cancels the close', () => {
|
||||
stubZh()
|
||||
const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })]
|
||||
const { entryEls, onClose, unmount } = mountRail(entries)
|
||||
try {
|
||||
act(() => {
|
||||
entryEls[0]!.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true, cancelable: true }))
|
||||
})
|
||||
act(() => {
|
||||
// Release on a different target (document.body, not the entry).
|
||||
document.body.dispatchEvent(new MouseEvent('mouseup', { button: 1, bubbles: true }))
|
||||
})
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -31,12 +31,12 @@ function stubZh(): void {
|
||||
|
||||
const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
|
||||
|
||||
function mountBar(tabs: SidebarTab[], opts: { onPinTab?: ReturnType<typeof vi.fn> } = {}): {
|
||||
function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void } = {}): {
|
||||
tabEls: HTMLElement[]
|
||||
onClose: ReturnType<typeof vi.fn>
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
onFloatTab: ReturnType<typeof vi.fn>
|
||||
onPinTab?: ReturnType<typeof vi.fn>
|
||||
onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
@@ -233,7 +233,7 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
|
||||
it('adds a "Pin Terminal ▸" submenu for an unpinned UI terminal', () => {
|
||||
stubZh()
|
||||
const onPinTab = vi.fn()
|
||||
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
|
||||
const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab })
|
||||
try {
|
||||
act(() => { rightClick(tabEls[2]!) }) // t3 = UI terminal
|
||||
@@ -259,7 +259,7 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
|
||||
it('uses the "Pin Agent Terminal" label for an agent terminal tab', () => {
|
||||
stubZh()
|
||||
const onPinTab = vi.fn()
|
||||
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
|
||||
const tabs: SidebarTab[] = [
|
||||
{ id: 'agent:abc-123', type: 'terminal', title: 'Agent T' },
|
||||
]
|
||||
@@ -275,7 +275,7 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
|
||||
it('shows "Unpin" instead of the submenu for an already-pinned terminal', () => {
|
||||
stubZh()
|
||||
const onPinTab = vi.fn()
|
||||
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
|
||||
const tabs: SidebarTab[] = [
|
||||
{ id: 'terminal:1', type: 'terminal', title: 'T', pin: { scope: 'global' } },
|
||||
]
|
||||
@@ -295,7 +295,7 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
|
||||
it('does not add a pin entry for non-terminal tabs even with onPinTab provided', () => {
|
||||
stubZh()
|
||||
const onPinTab = vi.fn()
|
||||
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
|
||||
const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab })
|
||||
try {
|
||||
act(() => { rightClick(tabEls[0]!) }) // t1 = editor
|
||||
|
||||
Reference in New Issue
Block a user