refactor(ui-workspace): extract shared drag-accept and status-dot renderers

The flat-list ordering change duplicated the document-level native-drag
acceptance effect and the status-dot block across the search and session
rows, tripping the duplication gate. Extract useNativeDragAcceptance and
SessionStatusDots so both call sites share one implementation.
This commit is contained in:
_Kerman
2026-08-12 12:01:23 +08:00
parent 3a3248b31d
commit 64326cb597
2 changed files with 38 additions and 45 deletions
@@ -54,6 +54,28 @@ function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
}
/**
* Accept the native drag at document level while a row drag is active: row
* hover still owns the insertion marker, and releasing outside the list must
* not be rendered as a rejected drop before dragend commits that last marker.
*/
function useNativeDragAcceptance(active: boolean): void {
useEffect(() => {
if (!active) return
const acceptDrag = (event: DragEvent): void => {
event.preventDefault()
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
}
const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
document.addEventListener('dragover', acceptDrag)
document.addEventListener('drop', acceptDrop)
return () => {
document.removeEventListener('dragover', acceptDrag)
document.removeEventListener('drop', acceptDrop)
}
}, [active])
}
/** Reconcile a stored view order with the Workspace's current session account. */
function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] {
if (stored === undefined) return [...sessionIds]
@@ -241,23 +263,7 @@ function SessionTree({
const workspaceDropCommitted = useRef(false)
const previousOrderBy = useRef(orderBy)
const nativeDragActive = drag !== null || workspaceDrag !== null
useEffect(() => {
if (!nativeDragActive) return
// Row hover still owns the insertion marker. Accept the native drag at
// document level so releasing outside the list is not rendered as a
// rejected drop before dragend commits that last marker.
const acceptDrag = (event: DragEvent): void => {
event.preventDefault()
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
}
const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
document.addEventListener('dragover', acceptDrag)
document.addEventListener('drop', acceptDrop)
return () => {
document.removeEventListener('dragover', acceptDrag)
document.removeEventListener('drop', acceptDrop)
}
}, [nativeDragActive])
useNativeDragAcceptance(nativeDragActive)
const currentGroup = current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
@@ -590,20 +596,7 @@ function FlatList({
}, [baseRows, recentSessionOrder, sessionIds])
const [drag, setDrag] = useState<DragState | null>(null)
const dropCommitted = useRef(false)
useEffect(() => {
if (drag === null) return
const acceptDrag = (event: DragEvent): void => {
event.preventDefault()
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
}
const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
document.addEventListener('dragover', acceptDrag)
document.addEventListener('drop', acceptDrop)
return () => {
document.removeEventListener('dragover', acceptDrag)
document.removeEventListener('drop', acceptDrop)
}
}, [drag])
useNativeDragAcceptance(drag !== null)
const commitDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
if (dropCommitted.current) return
dropCommitted.current = true
@@ -259,6 +259,18 @@ function sessionStatuses(
return [{ state: 'done', label: t('status.idle') }]
}
/** Primary status dot plus every status's screen-reader label, shared by the search and session rows. */
function SessionStatusDots({ statuses }: { statuses: readonly [SessionStatus, ...SessionStatus[]] }) {
return (
<>
<StateDot state={statuses[0].state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)
}
/** Hover-card body: full title, relative time, and every relevant live status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const statuses = sessionStatuses(node, t)
@@ -308,12 +320,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
<span className={css.searchResultHeading}>
<span className={css.slot}>
{(primaryStatus.state !== 'done' || result.completed) && (
<>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
<SessionStatusDots statuses={statuses} />
)}
</span>
<span className={css.searchResultTitle}>{result.title}</span>
@@ -417,14 +424,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
and is cleared by opening the session. */}
{(!flat || showStatus) && (
<span className={css.slot}>
{showStatus && (
<>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
{showStatus && <SessionStatusDots statuses={statuses} />}
</span>
)}
<span className={css.title}>{title}</span>