refactor(client): 抽取 pinned 虚拟 tab 注入与动作包装到 sidebar/use-pinned-tabs.ts
Sidebar.tsx 拆分第四块:activePinnedTabId/pinnedRevision 状态、 pinnedEntries/pinnedVirtualTabs/augmentedTree 三个 memo(含 exhaustive-deps 豁免注释随迁)与 wrappedActions(虚拟 id 路由回 HOME 会话的 reduceFor + ptyClose 语义)原样迁出,注释逐字保留。 调用点移到 actions memo 之后(hook 无 effect,仅 useState/useMemo, 位置调整不改变行为)。 行为不变:memo 依赖数组逐字相同;wrappedActions 的 actions 入参即 原先闭包捕获的同一个 memo 产物。
This commit is contained in:
+8
-124
@@ -35,13 +35,13 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab,
|
||||
isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat,
|
||||
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
|
||||
import { getPinnedHomeScope } from './pinned.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
||||
@@ -53,6 +53,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
|
||||
import { useCenterColumn } from './sidebar/use-center-column.ts'
|
||||
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
|
||||
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
|
||||
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { FreeWindow } from './FreeWindow.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -349,53 +350,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// piece the render side consumes (renderTab's onSubagentJump arms it).
|
||||
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
|
||||
|
||||
/**
|
||||
/**
|
||||
* 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)
|
||||
* 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
|
||||
* the home session's PTY via WS, no session jump).
|
||||
*
|
||||
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
|
||||
* (which doesn't notify — targeted opens must not re-render the active
|
||||
* session). The `pinnedRevision` state bump forces the pinnedEntries
|
||||
* useMemo to recompute after such an action.
|
||||
*/
|
||||
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
|
||||
const [pinnedRevision, setPinnedRevision] = useState(0)
|
||||
|
||||
/**
|
||||
* Cross-session pinned-tab collection. Recomputed on every store notify,
|
||||
* session-list change, and pinned action (the revision bump covers
|
||||
* reduceFor updates that don't notify). Only 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 })
|
||||
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
|
||||
// above): they recompute this memo for changes that carry no dep of their own.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store, sessionId, cwd, snapshot, pinnedRevision])
|
||||
|
||||
/** Virtual SidebarTab objects for the pinned entries (stable references
|
||||
* via useMemo so TabContent's memo comparator holds). */
|
||||
const pinnedVirtualTabs = useMemo(
|
||||
() => pinnedEntries.map(createPinnedVirtualTab),
|
||||
[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. */
|
||||
const augmentedTree = useMemo(
|
||||
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
|
||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||
)
|
||||
|
||||
// Center-column tracking (sidebar/use-center-column.ts): the bottom panel
|
||||
// spans ONLY the app shell's center column ("squeezes the agent output
|
||||
// area"); the hook locates the AppFrame's center column DOM (host anchor
|
||||
@@ -855,81 +809,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
},
|
||||
}), [store, sessionId, cwd, ctx, centerColRef])
|
||||
|
||||
/**
|
||||
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
||||
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
||||
* detected by the `pinned:` prefix and routed to the HOME session via
|
||||
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
||||
*/
|
||||
const wrappedActions = useMemo<WorkbenchActions>(() => {
|
||||
if (pinnedVirtualTabs.length === 0) return actions
|
||||
const closePinnedInHome = (virtualId: string): void => {
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
|
||||
// The home cwd lives in the virtual tab's meta (snapshotted at pin
|
||||
// time) — pass it to ptyClose so the host resolves the PTY in the
|
||||
// correct workspace container (same scope the WS open used).
|
||||
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
|
||||
})
|
||||
if (isAgentTabId(originalId)) {
|
||||
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
|
||||
} else {
|
||||
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
|
||||
}
|
||||
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
}
|
||||
return {
|
||||
...actions,
|
||||
activateTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
setActivePinnedTabId(tabId)
|
||||
} else {
|
||||
setActivePinnedTabId(null)
|
||||
actions.activateTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
closeTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
closePinnedInHome(tabId)
|
||||
} else {
|
||||
actions.closeTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
moveTabBefore: (payload, toPane, beforeTabId) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
if (isPinnedVirtualId(beforeTabId)) {
|
||||
actions.moveTabToEdge(payload, toPane, 'center')
|
||||
} else {
|
||||
actions.moveTabBefore(payload, toPane, beforeTabId)
|
||||
}
|
||||
},
|
||||
moveTabToEdge: (payload, toPane, zone) => {
|
||||
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
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
|
||||
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
|
||||
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
} else {
|
||||
actions.pinTab?.(tabId, scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
||||
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
|
||||
// tabs inject into the right panel's first leaf, and the actions are
|
||||
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
|
||||
// revision bump).
|
||||
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button. Directories append the folder mention
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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
|
||||
* 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,
|
||||
type SidebarSnapshot, type SidebarStore, type SplitNode,
|
||||
} from '../state.ts'
|
||||
import {
|
||||
collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree,
|
||||
isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry,
|
||||
} from '../pinned.ts'
|
||||
import type { WorkbenchActions } from '../split-pane.tsx'
|
||||
import { api } from '../api.ts'
|
||||
|
||||
export function usePinnedTabs(input: {
|
||||
store: SidebarStore
|
||||
sessionId: string | undefined
|
||||
cwd: string | undefined
|
||||
snapshot: SidebarSnapshot
|
||||
actions: WorkbenchActions
|
||||
}): { augmentedTree: SplitNode | undefined; wrappedActions: WorkbenchActions } {
|
||||
const { store, sessionId, cwd, snapshot, actions } = input
|
||||
const state = snapshot.state
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* 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
|
||||
* the home session's PTY via WS, no session jump).
|
||||
*
|
||||
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
|
||||
* (which doesn't notify — targeted opens must not re-render the active
|
||||
* session). The `pinnedRevision` state bump forces the pinnedEntries
|
||||
* useMemo to recompute after such an action.
|
||||
*/
|
||||
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
|
||||
const [pinnedRevision, setPinnedRevision] = useState(0)
|
||||
|
||||
/**
|
||||
* Cross-session pinned-tab collection. Recomputed on every store notify,
|
||||
* session-list change, and pinned action (the revision bump covers
|
||||
* reduceFor updates that don't notify). Only 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 })
|
||||
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
|
||||
// above): they recompute this memo for changes that carry no dep of their own.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store, sessionId, cwd, snapshot, pinnedRevision])
|
||||
|
||||
/** Virtual SidebarTab objects for the pinned entries (stable references
|
||||
* via useMemo so TabContent's memo comparator holds). */
|
||||
const pinnedVirtualTabs = useMemo(
|
||||
() => pinnedEntries.map(createPinnedVirtualTab),
|
||||
[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. */
|
||||
const augmentedTree = useMemo(
|
||||
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
|
||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||
)
|
||||
|
||||
/**
|
||||
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
||||
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
||||
* detected by the `pinned:` prefix and routed to the HOME session via
|
||||
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
||||
*/
|
||||
const wrappedActions = useMemo<WorkbenchActions>(() => {
|
||||
if (pinnedVirtualTabs.length === 0) return actions
|
||||
const closePinnedInHome = (virtualId: string): void => {
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
|
||||
// The home cwd lives in the virtual tab's meta (snapshotted at pin
|
||||
// time) — pass it to ptyClose so the host resolves the PTY in the
|
||||
// correct workspace container (same scope the WS open used).
|
||||
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
|
||||
})
|
||||
if (isAgentTabId(originalId)) {
|
||||
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
|
||||
} else {
|
||||
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
|
||||
}
|
||||
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
}
|
||||
return {
|
||||
...actions,
|
||||
activateTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
setActivePinnedTabId(tabId)
|
||||
} else {
|
||||
setActivePinnedTabId(null)
|
||||
actions.activateTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
closeTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
closePinnedInHome(tabId)
|
||||
} else {
|
||||
actions.closeTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
moveTabBefore: (payload, toPane, beforeTabId) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
if (isPinnedVirtualId(beforeTabId)) {
|
||||
actions.moveTabToEdge(payload, toPane, 'center')
|
||||
} else {
|
||||
actions.moveTabBefore(payload, toPane, beforeTabId)
|
||||
}
|
||||
},
|
||||
moveTabToEdge: (payload, toPane, zone) => {
|
||||
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
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
|
||||
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
|
||||
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
} else {
|
||||
actions.pinTab?.(tabId, scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
||||
|
||||
return { augmentedTree, wrappedActions }
|
||||
}
|
||||
Reference in New Issue
Block a user