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 type { Context } from '../context-types.ts'
|
||||||
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||||
import {
|
import {
|
||||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab,
|
||||||
isAgentTabId, leafWithTab, migrateBottomTabs,
|
isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||||
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat,
|
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat,
|
||||||
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
|
type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
|
||||||
} from './state.ts'
|
} 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 { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||||
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
||||||
@@ -53,6 +53,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts'
|
|||||||
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
|
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
|
||||||
import { useCenterColumn } from './sidebar/use-center-column.ts'
|
import { useCenterColumn } from './sidebar/use-center-column.ts'
|
||||||
import { useHostFeeds } from './sidebar/use-host-feeds.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 { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||||
import { FreeWindow } from './FreeWindow.tsx'
|
import { FreeWindow } from './FreeWindow.tsx'
|
||||||
import { relativeTo } from './paths.ts'
|
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).
|
// piece the render side consumes (renderTab's onSubagentJump arms it).
|
||||||
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
|
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
|
// Center-column tracking (sidebar/use-center-column.ts): the bottom panel
|
||||||
// spans ONLY the app shell's center column ("squeezes the agent output
|
// spans ONLY the app shell's center column ("squeezes the agent output
|
||||||
// area"); the hook locates the AppFrame's center column DOM (host anchor
|
// 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])
|
}), [store, sessionId, cwd, ctx, centerColRef])
|
||||||
|
|
||||||
/**
|
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
|
||||||
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
// tabs inject into the right panel's first leaf, and the actions are
|
||||||
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
|
||||||
* detected by the `pinned:` prefix and routed to the HOME session via
|
// revision bump).
|
||||||
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
|
||||||
*/
|
|
||||||
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])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The explorer's @-reference button. Directories append the folder mention
|
* 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