Skip to content

Commit f7332f3

Browse files
authored
Merge pull request #542 from omdsh-dev/feat/split-sidebar
feat(client): Sidebar.tsx 按关注点增量拆分
2 parents 68839aa + e105b01 commit f7332f3

6 files changed

Lines changed: 887 additions & 708 deletions

File tree

src/client/Sidebar.tsx

Lines changed: 53 additions & 708 deletions
Large diffs are not rendered by default.

src/client/sidebar/TabContent.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* The tab-content cell + the + menu builder, extracted from Sidebar.tsx
3+
* (behavior identical): one memoized cell dispatches a tab to its registry
4+
* descriptor's component, and buildNewTabOptions derives the + menu rows
5+
* from the same registry.
6+
*/
7+
import { createElement, memo } from 'react'
8+
import type { Context } from '../../context-types.ts'
9+
import type { SidebarState, SidebarStore, SidebarTab } from '../state.ts'
10+
import type { SessionScope } from '../api.ts'
11+
import { OrphanedTab } from '../OrphanedTab.tsx'
12+
import { RenderBoundary } from '../RenderBoundary.tsx'
13+
import { tabContentCompare, type TabContentMemoKey } from '../tab-content-memo.ts'
14+
import type { NewTabOption } from '../TabBar.tsx'
15+
import css from '../sidebar.module.css'
16+
17+
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
18+
* the runtime objects/callbacks the cell renders with. The memo comparator
19+
* is the pure `tabContentCompare`; anything in the key decides a re-render
20+
* must propagate, anything outside it must be a stable object (ctx/store)
21+
* or covered by a compared field (paneId covers onOpenDiff's captured
22+
* pane; sessionId/cwd cover onReferenceFile). */
23+
interface TabContentProps extends TabContentMemoKey {
24+
onToggleDir: (path: string) => void
25+
onReferenceFile: (path: string, isDir: boolean) => void
26+
ctx: Context
27+
store: SidebarStore
28+
/** Fired before a topology node jumps to its child session (see Sidebar). */
29+
onSubagentJump: (childSessionId: string) => void
30+
/** Open a diff tab from the git panel (placement handled by the store). */
31+
onOpenDiff: (tab: SidebarTab) => void
32+
}
33+
34+
/** Render the content of one tab (dispatched by type). */
35+
export const TabContent = memo(function TabContent(props: TabContentProps) {
36+
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
37+
const scope = { sessionId, cwd }
38+
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
39+
if (descriptor === undefined) {
40+
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
41+
}
42+
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
43+
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
44+
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
45+
// restores the real id at the component boundary.
46+
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
47+
return createElement(
48+
RenderBoundary,
49+
{ className: css.tabBoundaryError },
50+
createElement(descriptor.component, {
51+
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
52+
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
53+
}),
54+
)
55+
}, tabContentCompare)
56+
57+
/** The + menu options for the current state, driven by the tab registry.
58+
* Hidden tabs (editor/diff) never show; `available` returning false shows
59+
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
60+
* Tabs the user disabled in the side card settings are filtered out
61+
* entirely — re-enabling them is the settings page's job. */
62+
export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
63+
const service = ctx.get('betterSidebar')
64+
if (service === undefined) return []
65+
return service.getTabs()
66+
.filter(d => !d.hidden && service.isTabEnabled(d.id))
67+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
68+
.map(d => ({
69+
id: d.id,
70+
label: typeof d.title === 'function' ? d.title() : d.title,
71+
disabled: !(d.available?.(ctx, scope, state) ?? true),
72+
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
73+
}))
74+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Free windows (extracted from Sidebar.tsx, behavior identical): the
3+
* drag-out gesture that floats a tab onto the conversation column, plus the
4+
* render layer for the floating windows and its drop-zone hint overlay.
5+
*/
6+
import { useEffect, useRef, useState, type ReactNode } from 'react'
7+
import type { Context } from '../../context-types.ts'
8+
import {
9+
dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
10+
type FloatWindow, type SidebarStore, type SidebarTab,
11+
} from '../state.ts'
12+
import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
13+
import { FreeWindow } from '../FreeWindow.tsx'
14+
import { t } from '../locales.ts'
15+
import css from '../sidebar.module.css'
16+
17+
/** The dashed drop-zone hint's geometry (viewport coordinates). */
18+
export interface FloatDropHint {
19+
left: number
20+
top: number
21+
width: number
22+
height: number
23+
}
24+
25+
/**
26+
* Free windows — drag-out detection. The tab strips already drive HTML5
27+
* DnD (payload application/x-dsh-tab) with drops owned by the panes
28+
* (split/merge); this hook watches the DOCUMENT (capture) for the same
29+
* drag hovering OUTSIDE the panel host: while the pointer is over the
30+
* conversation column it arms the drop (preventDefault) and shows a hint
31+
* overlay there, and the drop floats the tab at the release point. Targets
32+
* inside the host are ignored here, so pane drops keep their behavior
33+
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
34+
* OS file drags and any DSH drags pass through). Narrow viewports skip
35+
* the gesture — the merged drawer covers the conversation, leaving
36+
* nothing to drop onto (the tab context menu entry still floats tabs).
37+
*/
38+
export function useFloatDragout(input: {
39+
narrow: boolean
40+
sessionId: string | undefined
41+
store: SidebarStore
42+
/** The AppFrame center column (from useCenterColumn); stable ref object. */
43+
centerColRef: { readonly current: HTMLElement | null }
44+
}): { floatHint: FloatDropHint | null } {
45+
const { narrow, sessionId, store, centerColRef } = input
46+
const [floatHint, setFloatHint] = useState<FloatDropHint | null>(null)
47+
const floatHintRef = useRef(false)
48+
useEffect(() => {
49+
if (narrow || sessionId === undefined) return
50+
const inPanelHost = (target: EventTarget | null): boolean =>
51+
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
52+
/** The conversation column's rect when the pointer is over it (and not
53+
* over our own surfaces); null otherwise. */
54+
const overConversation = (event: DragEvent): DOMRect | null => {
55+
if (inPanelHost(event.target)) return null
56+
const col = centerColRef.current
57+
if (col === null || !col.isConnected) return null
58+
const rect = col.getBoundingClientRect()
59+
if (rect.width === 0 || rect.height === 0) return null
60+
const { clientX: x, clientY: y } = event
61+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
62+
return rect
63+
}
64+
const onDragOver = (event: DragEvent): void => {
65+
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
66+
const rect = overConversation(event)
67+
if (rect !== null) {
68+
// preventDefault on dragover is what makes the browser deliver the
69+
// drop (and drop the "no" cursor) over the conversation area.
70+
event.preventDefault()
71+
setFloatHint((prev) => {
72+
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
73+
if (prev !== null && prev.left === next.left && prev.top === next.top
74+
&& prev.width === next.width && prev.height === next.height) return prev
75+
return next
76+
})
77+
floatHintRef.current = true
78+
} else if (floatHintRef.current) {
79+
floatHintRef.current = false
80+
setFloatHint(null)
81+
}
82+
}
83+
const onDrop = (event: DragEvent): void => {
84+
if (!floatHintRef.current) return
85+
floatHintRef.current = false
86+
setFloatHint(null)
87+
const rect = overConversation(event)
88+
if (rect === null) return
89+
event.preventDefault()
90+
event.stopPropagation()
91+
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
92+
if (payload === null) return
93+
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
94+
}
95+
const clear = (): void => {
96+
if (!floatHintRef.current) return
97+
floatHintRef.current = false
98+
setFloatHint(null)
99+
}
100+
document.addEventListener('dragover', onDragOver, true)
101+
document.addEventListener('drop', onDrop, true)
102+
window.addEventListener('dragend', clear, true)
103+
window.addEventListener('blur', clear)
104+
return () => {
105+
document.removeEventListener('dragover', onDragOver, true)
106+
document.removeEventListener('drop', onDrop, true)
107+
window.removeEventListener('dragend', clear, true)
108+
window.removeEventListener('blur', clear)
109+
}
110+
// centerColRef is a stable ref object from useCenterColumn (it lost its
111+
// useRef provenance crossing the hook boundary, so it is listed here).
112+
}, [narrow, sessionId, store, centerColRef])
113+
114+
return { floatHint }
115+
}
116+
117+
/**
118+
* The free-window render layer: tabs dragged out onto the conversation area
119+
* (or floated from the tab context menu). They live in the panel host like
120+
* the panels (viewport coordinates, immune to desktop-shell transforms) but
121+
* are independent of panel state — a window stays up while panels collapse.
122+
* The floats array's order is the stacking order; the content reuses the
123+
* regular tab renderer, so every tab type floats unchanged. Renders a
124+
* fragment so the DOM is exactly the floats followed by the drag-out hint.
125+
*/
126+
export function FreeWindowLayer(props: {
127+
floats: readonly FloatWindow[]
128+
hint: FloatDropHint | null
129+
renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
130+
getTabIcon: (tab: SidebarTab) => ReactNode
131+
store: SidebarStore
132+
ctx: Context
133+
sessionId: string
134+
cwd: string | undefined
135+
}) {
136+
const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
137+
return (
138+
<>
139+
{floats.map(float => (
140+
<FreeWindow
141+
key={float.id}
142+
float={float}
143+
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
144+
getTabIcon={getTabIcon}
145+
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
146+
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
147+
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
148+
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
149+
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
150+
/>
151+
))}
152+
{/*
153+
The drag-out hint: while a tab drag hovers the conversation column,
154+
a dashed overlay marks the drop zone there (pointer-transparent — it
155+
must not disturb the drag it describes).
156+
*/}
157+
{hint !== null && (
158+
<div
159+
className={css.floatDropHint}
160+
style={{ left: hint.left, top: hint.top, width: hint.width, height: hint.height }}
161+
>
162+
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
163+
</div>
164+
)}
165+
</>
166+
)
167+
}

0 commit comments

Comments
 (0)