|
|
@@ -9,7 +9,7 @@
|
|
|
* menu in between; the flow and its error dialog live in WorkspacePicker
|
|
|
* (same package — direct composition, no slot between them).
|
|
|
*/
|
|
|
-import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
+import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
import clsx from 'clsx'
|
|
|
import {
|
|
|
Button, IconCloseFill14, IconPersonalizationOutline16,
|
|
|
@@ -21,13 +21,13 @@ import type {
|
|
|
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
|
|
-import type { SessionNode, SessionOrderBy } from '../tree.ts'
|
|
|
+import type { GroupNode, SessionNode, SessionOrderBy } from '../tree.ts'
|
|
|
import {
|
|
|
- deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey,
|
|
|
+ deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder,
|
|
|
pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
|
|
|
} from '../tree.ts'
|
|
|
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
|
|
|
-import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
|
|
|
+import { FLAT_SESSION_ORDER_KEY, type SessionGroupBy } from '../stores.ts'
|
|
|
import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
|
|
|
import css from './WorkspaceBrowser.module.css'
|
|
|
|
|
|
@@ -98,9 +98,9 @@ function useNativeDragAcceptance(active: boolean): void {
|
|
|
|
|
|
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
|
|
|
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
|
|
- groupBy: 'workspace' | 'flat'
|
|
|
+ groupBy: SessionGroupBy
|
|
|
orderBy: SessionOrderBy
|
|
|
- onGroupPick: (mode: 'workspace' | 'flat') => void
|
|
|
+ onGroupPick: (mode: SessionGroupBy) => void
|
|
|
onOrderPick: (mode: SessionOrderBy) => void
|
|
|
t: WorkspaceBrowserProps['t']
|
|
|
}) {
|
|
|
@@ -112,6 +112,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
|
|
items={[
|
|
|
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
|
|
{ id: 'workspace', label: t('groupBy.workspace') },
|
|
|
+ { id: 'workspace-tree', label: t('groupBy.workspaceTree') },
|
|
|
{ id: 'flat', label: t('groupBy.flat') },
|
|
|
{ type: 'separator' as const, id: 'order-by-separator' },
|
|
|
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
|
|
|
@@ -120,7 +121,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
|
|
]}
|
|
|
selectedIds={[groupBy, orderBy]}
|
|
|
onSelect={(id) => {
|
|
|
- if (id === 'workspace' || id === 'flat') onGroupPick(id)
|
|
|
+ if (id === 'workspace' || id === 'workspace-tree' || id === 'flat') onGroupPick(id)
|
|
|
else if (id === 'manual' || id === 'updated') onOrderPick(id)
|
|
|
setOpen(false)
|
|
|
}}
|
|
|
@@ -160,7 +161,7 @@ interface WorkspaceDragState {
|
|
|
over: { id: WorkspaceId; half: 'before' | 'after' } | null
|
|
|
}
|
|
|
|
|
|
-/** Resolve an insertion side from the full rendered workspace group. */
|
|
|
+/** Resolve an insertion side across the Workspace header, descendants, and Sessions. */
|
|
|
function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
|
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
|
|
@@ -181,9 +182,11 @@ type SessionTreeProps = Pick<
|
|
|
ungroupedSessionIds: readonly SessionId[]
|
|
|
/** Whether the current Workspace stream has a complete Host baseline. */
|
|
|
workspaceReady: boolean
|
|
|
- /** Explicit persisted zero-or-five-session state by Workspace group. */
|
|
|
+ /** Nest Workspaces under their nearest registered ancestors. */
|
|
|
+ nestWorkspaces: boolean
|
|
|
+ /** Explicit persisted group expansion, including descendants in tree mode. */
|
|
|
groupExpansion: Readonly<Record<string, boolean>>
|
|
|
- /** Persist one Workspace group's zero-or-five-session state. */
|
|
|
+ /** Persist one Workspace group's expansion. */
|
|
|
setGroupExpanded: (key: string, expanded: boolean) => void
|
|
|
/** Save a drag order and select Manual. */
|
|
|
setSessionOrder: (accountKey: string, order: readonly string[]) => void
|
|
|
@@ -210,7 +213,7 @@ function SessionTree({
|
|
|
workspaceReady, usePanelInfo,
|
|
|
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
|
|
|
insertWorkspaceBefore,
|
|
|
- groupExpansion, setGroupExpanded,
|
|
|
+ nestWorkspaces, groupExpansion, setGroupExpanded,
|
|
|
setSessionOrder, home, t,
|
|
|
revealSessionId, onSessionRevealed,
|
|
|
}: SessionTreeProps) {
|
|
|
@@ -235,10 +238,27 @@ function SessionTree({
|
|
|
if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return
|
|
|
setGroupExpanded(currentGroup, true)
|
|
|
}, [current, currentGroup, setGroupExpanded, groupExpansion])
|
|
|
- const expandedGroups = useMemo(
|
|
|
- () => Object.entries(groupExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
|
|
|
- [groupExpansion],
|
|
|
- )
|
|
|
+ const parents = useMemo(() => {
|
|
|
+ if (!nestWorkspaces) return new Map<string, WorkspaceId | undefined>()
|
|
|
+ const keysByPath = new Map(workspaces.map(workspace => [workspace.path, workspace.workspaceId]))
|
|
|
+ const paths = [...keysByPath.keys()]
|
|
|
+ return new Map<string, WorkspaceId | undefined>(workspaces.map((workspace) => {
|
|
|
+ const path = owningParentFolder(workspace.path, paths)
|
|
|
+ return [workspace.workspaceId, path === undefined ? undefined : keysByPath.get(path)]
|
|
|
+ }))
|
|
|
+ }, [nestWorkspaces, workspaces])
|
|
|
+ const currentAncestors = useMemo(() => {
|
|
|
+ const keys = new Set<string>()
|
|
|
+ for (let key = currentGroup === undefined ? undefined : parents.get(currentGroup); key !== undefined; key = parents.get(key)) {
|
|
|
+ keys.add(key)
|
|
|
+ }
|
|
|
+ return keys
|
|
|
+ }, [currentGroup, parents])
|
|
|
+ const expandedGroups = useMemo(() => {
|
|
|
+ const ancestorKeys = new Set<string | undefined>(parents.values())
|
|
|
+ return [...workspaces.map(workspace => workspace.workspaceId), UNGROUPED_KEY]
|
|
|
+ .filter(key => groupExpansion[key] ?? ancestorKeys.has(key))
|
|
|
+ }, [groupExpansion, parents, workspaces])
|
|
|
const groups = useMemo(
|
|
|
() => deriveGroups(list, workspaces, archivedSessionIds, pendingInteractions, {
|
|
|
expandedGroups,
|
|
|
@@ -247,9 +267,12 @@ function SessionTree({
|
|
|
[list, workspaces, archivedSessionIds, pendingInteractions, expandedGroups, ungroupedSessionIds],
|
|
|
)
|
|
|
useEffect(() => {
|
|
|
- if (revealGroup === undefined || groupExpansion[revealGroup] === true) return
|
|
|
- setGroupExpanded(revealGroup, true)
|
|
|
- }, [groupExpansion, revealGroup, setGroupExpanded])
|
|
|
+ for (let key = revealGroup; key !== undefined; key = parents.get(key)) {
|
|
|
+ if (groupExpansion[key] === false || (key === revealGroup && groupExpansion[key] !== true)) {
|
|
|
+ setGroupExpanded(key, true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [groupExpansion, parents, revealGroup, setGroupExpanded])
|
|
|
useEffect(() => {
|
|
|
if (revealSessionId === undefined || revealGroup === undefined) return
|
|
|
const group = groups.find(candidate => candidate.key === revealGroup)
|
|
|
@@ -314,188 +337,225 @@ function SessionTree({
|
|
|
if (workspaceDropCommitted.current) return
|
|
|
workspaceDropCommitted.current = true
|
|
|
setWorkspaceDrag(null)
|
|
|
- const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === over.id)
|
|
|
+ const owner = parents.get(activeDrag.workspaceId)
|
|
|
+ const siblings = workspaces.filter(workspace => parents.get(workspace.workspaceId) === owner)
|
|
|
+ const rowIndex = siblings.findIndex(workspace => workspace.workspaceId === over.id)
|
|
|
if (rowIndex === -1) return
|
|
|
- const anchor = over.half === 'before' ? over.id : workspaces[rowIndex + 1]?.workspaceId
|
|
|
+ const anchor = over.half === 'before' ? over.id : siblings[rowIndex + 1]?.workspaceId
|
|
|
if (anchor === activeDrag.workspaceId) return
|
|
|
- const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
|
|
|
+ const sourceIndex = siblings.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
|
|
|
const anchorIndex = anchor === undefined
|
|
|
- ? workspaces.length
|
|
|
- : workspaces.findIndex(workspace => workspace.workspaceId === anchor)
|
|
|
+ ? siblings.length
|
|
|
+ : siblings.findIndex(workspace => workspace.workspaceId === anchor)
|
|
|
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
|
insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => {
|
|
|
console.warn('workspace reorder rejected:', reason)
|
|
|
})
|
|
|
}
|
|
|
- const workspaceDropAtListStart = groups[0]?.workspaceId !== undefined
|
|
|
- && workspaceDrag?.over?.id === groups[0].workspaceId
|
|
|
+ const childrenByParent = useMemo(() => {
|
|
|
+ const children = new Map<string | undefined, GroupNode[]>()
|
|
|
+ for (const group of groups) {
|
|
|
+ const parent = parents.get(group.key)
|
|
|
+ const siblings = children.get(parent)
|
|
|
+ if (siblings === undefined) children.set(parent, [group])
|
|
|
+ else siblings.push(group)
|
|
|
+ }
|
|
|
+ return children
|
|
|
+ }, [groups, parents])
|
|
|
+ const rootGroups = childrenByParent.get(undefined) ?? []
|
|
|
+ const workspaceDropAtListStart = rootGroups[0]?.workspaceId !== undefined
|
|
|
+ && workspaceDrag?.over?.id === rootGroups[0].workspaceId
|
|
|
&& workspaceDrag.over.half === 'before'
|
|
|
|
|
|
- return (
|
|
|
- <div className={clsx(css.treeBody, css.wide)}>
|
|
|
- {workspaceDropAtListStart && <span className={css.listTopDropIndicator} aria-hidden="true" />}
|
|
|
+ const renderGroup = (group: GroupNode, depth: number): ReactNode => {
|
|
|
+ const workspaceId = group.workspaceId
|
|
|
+ const children = childrenByParent.get(group.key) ?? []
|
|
|
+ const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
|
|
|
+ const collapsed = collapsedSessionRows(group.sessions)
|
|
|
+ const sessionsExpanded = expandedSessionGroups.includes(group.key)
|
|
|
+ const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
|
|
|
+ ? workspaceDrag.over.half
|
|
|
+ : null
|
|
|
+ const workspaceDragProps = workspaceId === undefined ? undefined : {
|
|
|
+ start: () => {
|
|
|
+ workspaceDropCommitted.current = false
|
|
|
+ setWorkspaceDrag({ workspaceId, over: null })
|
|
|
+ },
|
|
|
+ end: () => {
|
|
|
+ if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) {
|
|
|
+ commitWorkspaceDrag(workspaceDrag, workspaceDrag.over)
|
|
|
+ } else {
|
|
|
+ setWorkspaceDrag(null)
|
|
|
+ }
|
|
|
+ workspaceDropCommitted.current = false
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const hoverWorkspace = workspaceId === undefined || !compatibleDrag
|
|
|
+ ? undefined
|
|
|
+ : (half: 'before' | 'after') => {
|
|
|
+ setWorkspaceDrag(active => active === null
|
|
|
+ ? active
|
|
|
+ : { ...active, over: { id: workspaceId, half } })
|
|
|
+ }
|
|
|
+ const dropWorkspace = workspaceId === undefined || !compatibleDrag
|
|
|
+ ? undefined
|
|
|
+ : (half: 'before' | 'after') => {
|
|
|
+ commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half })
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ // Group section: header, descendant Workspaces, and own Session rows. The
|
|
|
+ // inter-group breathing room is the section's own margin
|
|
|
+ // (WorkspaceBrowser.module.css).
|
|
|
<div
|
|
|
- className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)}
|
|
|
- role="tree"
|
|
|
- aria-label={t('section.sessions')}
|
|
|
+ key={group.key}
|
|
|
+ style={{ '--dsh-workspace-indent': `${depth * 12}px` } as CSSProperties}
|
|
|
+ className={clsx(
|
|
|
+ css.groupSection,
|
|
|
+ workspaceMarker === 'before' && css.workspaceDropBefore,
|
|
|
+ workspaceMarker === 'after' && css.workspaceDropAfter,
|
|
|
+ )}
|
|
|
+ onDragOver={workspaceDrag === null
|
|
|
+ ? undefined
|
|
|
+ : (e) => {
|
|
|
+ e.preventDefault()
|
|
|
+ if (hoverWorkspace === undefined && parents.get(group.key) !== undefined) return
|
|
|
+ e.stopPropagation()
|
|
|
+ if (hoverWorkspace === undefined) {
|
|
|
+ e.dataTransfer.dropEffect = 'none'
|
|
|
+ if (workspaceDrag.over !== null) setWorkspaceDrag({ ...workspaceDrag, over: null })
|
|
|
+ } else {
|
|
|
+ e.dataTransfer.dropEffect = 'move'
|
|
|
+ hoverWorkspace(workspaceGroupHalf(e))
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onDrop={workspaceDrag === null
|
|
|
+ ? undefined
|
|
|
+ : (e) => {
|
|
|
+ e.preventDefault()
|
|
|
+ if (dropWorkspace === undefined && parents.get(group.key) !== undefined) return
|
|
|
+ e.stopPropagation()
|
|
|
+ if (dropWorkspace === undefined) {
|
|
|
+ workspaceDropCommitted.current = true
|
|
|
+ setWorkspaceDrag(null)
|
|
|
+ } else {
|
|
|
+ dropWorkspace(workspaceGroupHalf(e))
|
|
|
+ }
|
|
|
+ }}
|
|
|
>
|
|
|
- {groups.length === 0 && (
|
|
|
- <div className={css.empty}>{t('empty.none')}</div>
|
|
|
+ <ProjectRowItem
|
|
|
+ group={group}
|
|
|
+ containsCurrentDescendant={currentAncestors.has(group.key)}
|
|
|
+ home={home}
|
|
|
+ t={t}
|
|
|
+ onToggle={() => {
|
|
|
+ if (group.expanded) {
|
|
|
+ setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
|
|
+ }
|
|
|
+ setGroupExpanded(group.key, !group.expanded)
|
|
|
+ }}
|
|
|
+ onCreate={() => {
|
|
|
+ if (group.workspaceId !== undefined) {
|
|
|
+ setGroupExpanded(group.key, true)
|
|
|
+ startSession(group.workspaceId)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ drag={workspaceDragProps}
|
|
|
+ actions={group.workspaceId === undefined
|
|
|
+ ? undefined
|
|
|
+ : {
|
|
|
+ rename: () => {
|
|
|
+ /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
|
|
+ if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
|
|
+ },
|
|
|
+ delete: () => {
|
|
|
+ /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
|
|
+ if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {group.expanded && children.length > 0 && (
|
|
|
+ <div role="group">
|
|
|
+ {children.map(child => renderGroup(child, depth + 1))}
|
|
|
+ </div>
|
|
|
)}
|
|
|
- {groups.map((group) => {
|
|
|
- const workspaceId = group.workspaceId
|
|
|
- const collapsed = collapsedSessionRows(group.sessions)
|
|
|
- const sessionsExpanded = expandedSessionGroups.includes(group.key)
|
|
|
- const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
|
|
|
- ? workspaceDrag.over.half
|
|
|
- : null
|
|
|
- const workspaceDragProps = workspaceId === undefined ? undefined : {
|
|
|
+ {(sessionsExpanded
|
|
|
+ ? group.sessions
|
|
|
+ : collapsed.rows
|
|
|
+ ).map((node) => {
|
|
|
+ // Session drag never leaves its browser-local account.
|
|
|
+ const sameGroupDrag = drag !== null && drag.accountKey === group.key
|
|
|
+ const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' =>
|
|
|
+ node.blank ? 'after' : half
|
|
|
+ const dragProps = {
|
|
|
start: () => {
|
|
|
- workspaceDropCommitted.current = false
|
|
|
- setWorkspaceDrag({ workspaceId, over: null })
|
|
|
+ sessionDropCommitted.current = false
|
|
|
+ setDrag({ accountKey: group.key, sessionId: node.id, over: null })
|
|
|
+ },
|
|
|
+ active: sameGroupDrag,
|
|
|
+ marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
|
|
+ hover: (half: 'before' | 'after') => {
|
|
|
+ /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
|
|
|
+ setDrag(d => (d === null ? d : {
|
|
|
+ ...d, over: { id: node.id, half: normalizeHalf(half) },
|
|
|
+ }))
|
|
|
+ },
|
|
|
+ drop: (half: 'before' | 'after') => {
|
|
|
+ /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
|
|
|
+ if (drag === null) return
|
|
|
+ commitSessionDrag(drag, { id: node.id, half: normalizeHalf(half) })
|
|
|
},
|
|
|
end: () => {
|
|
|
- if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) {
|
|
|
- commitWorkspaceDrag(workspaceDrag, workspaceDrag.over)
|
|
|
- } else {
|
|
|
- setWorkspaceDrag(null)
|
|
|
- }
|
|
|
- workspaceDropCommitted.current = false
|
|
|
+ if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
|
|
|
+ else setDrag(null)
|
|
|
+ sessionDropCommitted.current = false
|
|
|
},
|
|
|
}
|
|
|
- const hoverWorkspace = workspaceId === undefined
|
|
|
- ? undefined
|
|
|
- : (half: 'before' | 'after') => {
|
|
|
- setWorkspaceDrag(active => active === null
|
|
|
- ? active
|
|
|
- : { ...active, over: { id: workspaceId, half } })
|
|
|
- }
|
|
|
- const dropWorkspace = workspaceId === undefined
|
|
|
- ? undefined
|
|
|
- : (half: 'before' | 'after') => {
|
|
|
- if (workspaceDrag === null) return
|
|
|
- commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half })
|
|
|
- }
|
|
|
return (
|
|
|
- // Group section: header row + expanded top-level session rows. The
|
|
|
- // inter-group breathing room is the section's own margin
|
|
|
- // (WorkspaceBrowser.module.css).
|
|
|
- <div
|
|
|
- key={group.key}
|
|
|
- className={clsx(
|
|
|
- css.groupSection,
|
|
|
- workspaceMarker === 'before' && css.workspaceDropBefore,
|
|
|
- workspaceMarker === 'after' && css.workspaceDropAfter,
|
|
|
- )}
|
|
|
- onDragOver={workspaceDrag === null || hoverWorkspace === undefined
|
|
|
- ? undefined
|
|
|
- : (e) => {
|
|
|
- e.preventDefault()
|
|
|
- e.dataTransfer.dropEffect = 'move'
|
|
|
- hoverWorkspace(workspaceGroupHalf(e))
|
|
|
- }}
|
|
|
- onDrop={workspaceDrag === null || dropWorkspace === undefined
|
|
|
- ? undefined
|
|
|
- : (e) => {
|
|
|
- e.preventDefault()
|
|
|
- dropWorkspace(workspaceGroupHalf(e))
|
|
|
- }}
|
|
|
- >
|
|
|
- <ProjectRowItem
|
|
|
- group={group}
|
|
|
- home={home}
|
|
|
- t={t}
|
|
|
- onToggle={() => {
|
|
|
- if (group.expanded) {
|
|
|
- setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
|
|
- }
|
|
|
- setGroupExpanded(group.key, !group.expanded)
|
|
|
- }}
|
|
|
- onCreate={() => {
|
|
|
- if (group.workspaceId !== undefined) {
|
|
|
- setGroupExpanded(group.key, true)
|
|
|
- startSession(group.workspaceId)
|
|
|
- }
|
|
|
- }}
|
|
|
- drag={workspaceDragProps}
|
|
|
- actions={group.workspaceId === undefined
|
|
|
- ? undefined
|
|
|
- : {
|
|
|
- rename: () => {
|
|
|
- /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
|
|
- if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
|
|
- },
|
|
|
- delete: () => {
|
|
|
- /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
|
|
- if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
|
|
- },
|
|
|
- }}
|
|
|
- />
|
|
|
- {(sessionsExpanded
|
|
|
- ? group.sessions
|
|
|
- : collapsed.rows
|
|
|
- ).map((node) => {
|
|
|
- // Session drag never leaves its browser-local account.
|
|
|
- const sameGroupDrag = drag !== null && drag.accountKey === group.key
|
|
|
- const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' =>
|
|
|
- node.blank ? 'after' : half
|
|
|
- const dragProps = {
|
|
|
- start: () => {
|
|
|
- sessionDropCommitted.current = false
|
|
|
- setDrag({ accountKey: group.key, sessionId: node.id, over: null })
|
|
|
- },
|
|
|
- active: sameGroupDrag,
|
|
|
- marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
|
|
- hover: (half: 'before' | 'after') => {
|
|
|
- /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
|
|
|
- setDrag(d => (d === null ? d : {
|
|
|
- ...d, over: { id: node.id, half: normalizeHalf(half) },
|
|
|
- }))
|
|
|
- },
|
|
|
- drop: (half: 'before' | 'after') => {
|
|
|
- /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
|
|
|
- if (drag === null) return
|
|
|
- commitSessionDrag(drag, { id: node.id, half: normalizeHalf(half) })
|
|
|
- },
|
|
|
- end: () => {
|
|
|
- if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
|
|
|
- else setDrag(null)
|
|
|
- sessionDropCommitted.current = false
|
|
|
- },
|
|
|
- }
|
|
|
- return (
|
|
|
- <SessionNodeItem
|
|
|
- key={node.id}
|
|
|
- node={node}
|
|
|
- currentId={current}
|
|
|
- now={now}
|
|
|
- onOpen={open}
|
|
|
- onRename={onSessionRename}
|
|
|
- onFork={forkSession}
|
|
|
- onArchive={onSessionArchive}
|
|
|
- onReveal={node.id === revealSessionId && group.key === revealGroup
|
|
|
- ? () => { onSessionRevealed(node.id) }
|
|
|
- : undefined}
|
|
|
- drag={dragProps}
|
|
|
- t={t}
|
|
|
- />
|
|
|
- )
|
|
|
- })}
|
|
|
- {collapsed.hiddenCount > 0 && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.sessionOverflowButton}
|
|
|
- aria-expanded={sessionsExpanded}
|
|
|
- onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
|
|
|
- >
|
|
|
- {sessionsExpanded
|
|
|
- ? t('sessions.collapse')
|
|
|
- : t('sessions.expand', { n: collapsed.hiddenCount })}
|
|
|
- </button>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ <SessionNodeItem
|
|
|
+ key={node.id}
|
|
|
+ node={node}
|
|
|
+ currentId={current}
|
|
|
+ now={now}
|
|
|
+ onOpen={open}
|
|
|
+ onRename={onSessionRename}
|
|
|
+ onFork={forkSession}
|
|
|
+ onArchive={onSessionArchive}
|
|
|
+ onReveal={node.id === revealSessionId && group.key === revealGroup
|
|
|
+ ? () => { onSessionRevealed(node.id) }
|
|
|
+ : undefined}
|
|
|
+ drag={dragProps}
|
|
|
+ t={t}
|
|
|
+ />
|
|
|
)
|
|
|
})}
|
|
|
+ {collapsed.hiddenCount > 0 && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.sessionOverflowButton}
|
|
|
+ aria-expanded={sessionsExpanded}
|
|
|
+ onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
|
|
|
+ >
|
|
|
+ {sessionsExpanded
|
|
|
+ ? t('sessions.collapse')
|
|
|
+ : t('sessions.expand', { n: collapsed.hiddenCount })}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={clsx(css.treeBody, css.wide)}>
|
|
|
+ {workspaceDropAtListStart && <span className={css.listTopDropIndicator} aria-hidden="true" />}
|
|
|
+ <div
|
|
|
+ className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)}
|
|
|
+ role="tree"
|
|
|
+ aria-label={t('section.sessions')}
|
|
|
+ >
|
|
|
+ {groups.length === 0 && (
|
|
|
+ <div className={css.empty}>{t('empty.none')}</div>
|
|
|
+ )}
|
|
|
+ {rootGroups.map(group => renderGroup(group, 0))}
|
|
|
</div>
|
|
|
<span className={css.fade} />
|
|
|
</div>
|
|
|
@@ -776,7 +836,7 @@ export function WorkspaceBrowser({
|
|
|
)
|
|
|
}, [currentBlank, flatMemberIds, list.byId, orderBy, sessionOrderByAccount])
|
|
|
const activeSessionOrders = useMemo<Readonly<Record<string, readonly string[]>>>(() => Object.fromEntries([
|
|
|
- ...orderedWorkspaces.map(workspace => [workspace.workspaceId as string, workspace.sessionIds] as const),
|
|
|
+ ...orderedWorkspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const),
|
|
|
[UNGROUPED_KEY, orderedUngroupedSessionIds] as const,
|
|
|
[FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds] as const,
|
|
|
]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces])
|
|
|
@@ -785,7 +845,7 @@ export function WorkspaceBrowser({
|
|
|
actions.retainAccountKeys([
|
|
|
UNGROUPED_KEY,
|
|
|
FLAT_SESSION_ORDER_KEY,
|
|
|
- ...workspaces.map(workspace => workspace.workspaceId as string),
|
|
|
+ ...workspaces.map(workspace => workspace.workspaceId),
|
|
|
])
|
|
|
}, [actions.retainAccountKeys, workspacePhase, workspaces])
|
|
|
useEffect(() => {
|
|
|
@@ -1214,6 +1274,7 @@ export function WorkspaceBrowser({
|
|
|
workspaces={orderedWorkspaces}
|
|
|
ungroupedSessionIds={orderedUngroupedSessionIds}
|
|
|
workspaceReady={workspaceReady}
|
|
|
+ nestWorkspaces={groupBy === 'workspace-tree'}
|
|
|
groupExpansion={groupExpansion}
|
|
|
setGroupExpanded={actions.setGroupExpanded}
|
|
|
setSessionOrder={saveSessionOrder}
|