fix(client): preserve workspace browser interactions
This commit is contained in:
@@ -36,6 +36,7 @@ const SEARCH_DEBOUNCE_MS = 250
|
|||||||
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
||||||
/** Session rows visible per Workspace before the local overflow control. */
|
/** Session rows visible per Workspace before the local overflow control. */
|
||||||
const COLLAPSED_SESSION_LIMIT = 5
|
const COLLAPSED_SESSION_LIMIT = 5
|
||||||
|
const EMPTY_WORKSPACE_EXPANSION: Readonly<Record<string, boolean>> = Object.freeze({})
|
||||||
|
|
||||||
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
||||||
function sanitizeSearchQuery(value: string): string {
|
function sanitizeSearchQuery(value: string): string {
|
||||||
@@ -48,7 +49,7 @@ function sanitizeSearchQuery(value: string): string {
|
|||||||
return withoutNul.slice(0, end)
|
return withoutNul.slice(0, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immutable membership toggle for the local expansion arrays. */
|
/** Immutable membership toggle for the local expand-all array. */
|
||||||
function toggled(list: readonly string[], key: string): string[] {
|
function toggled(list: readonly string[], key: string): string[] {
|
||||||
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||||
}
|
}
|
||||||
@@ -116,12 +117,22 @@ interface WorkspaceDragState {
|
|||||||
over: { id: WorkspaceId; half: 'before' | 'after' } | null
|
over: { id: WorkspaceId; half: 'before' | 'after' } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve an insertion side from the full rendered workspace group. */
|
||||||
|
function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||||
|
}
|
||||||
|
|
||||||
type SessionTreeProps = Pick<
|
type SessionTreeProps = Pick<
|
||||||
WorkspaceBrowserProps,
|
WorkspaceBrowserProps,
|
||||||
'useSessions' | 'startSession' | 'open' | 'forkSession'
|
'useSessions' | 'startSession' | 'open' | 'forkSession'
|
||||||
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
|
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
|
||||||
> & {
|
> & {
|
||||||
workspaces: readonly WorkspaceView[]
|
workspaces: readonly WorkspaceView[]
|
||||||
|
/** Explicit persisted zero-or-five-session state by Workspace group. */
|
||||||
|
workspaceExpansion: Readonly<Record<string, boolean>>
|
||||||
|
/** Persist one Workspace group's zero-or-five-session state. */
|
||||||
|
setWorkspaceExpanded: (key: string, expanded: boolean) => void
|
||||||
/** Registry-global archive set (hidden rows). */
|
/** Registry-global archive set (hidden rows). */
|
||||||
archivedSessionIds: readonly SessionNode['id'][]
|
archivedSessionIds: readonly SessionNode['id'][]
|
||||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||||
@@ -136,15 +147,15 @@ type SessionTreeProps = Pick<
|
|||||||
orderBy: SessionOrderBy
|
orderBy: SessionOrderBy
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
|
||||||
function SessionTree({
|
function SessionTree({
|
||||||
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
||||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
|
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
|
||||||
insertWorkspaceBefore, insertSessionBefore, orderBy, t,
|
insertWorkspaceBefore, insertSessionBefore, orderBy,
|
||||||
|
workspaceExpansion, setWorkspaceExpanded, t,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
const list = useSessions(s => s)
|
const list = useSessions(s => s)
|
||||||
const current = list.current
|
const current = list.current
|
||||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
|
||||||
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
|
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
|
||||||
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
||||||
const [drag, setDrag] = useState<DragState | null>(null)
|
const [drag, setDrag] = useState<DragState | null>(null)
|
||||||
@@ -154,9 +165,13 @@ function SessionTree({
|
|||||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||||
?? UNGROUPED_KEY
|
?? UNGROUPED_KEY
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current === undefined || currentGroup === undefined) return
|
if (current === undefined || currentGroup === undefined || Object.hasOwn(workspaceExpansion, currentGroup)) return
|
||||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
setWorkspaceExpanded(currentGroup, true)
|
||||||
}, [current, currentGroup])
|
}, [current, currentGroup, setWorkspaceExpanded, workspaceExpansion])
|
||||||
|
const expandedProjects = useMemo(
|
||||||
|
() => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
|
||||||
|
[workspaceExpansion],
|
||||||
|
)
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy),
|
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy),
|
||||||
[list, workspaces, archivedSessionIds, expandedProjects, orderBy],
|
[list, workspaces, archivedSessionIds, expandedProjects, orderBy],
|
||||||
@@ -176,14 +191,18 @@ function SessionTree({
|
|||||||
: null
|
: null
|
||||||
const workspaceDragProps = workspaceId === undefined ? undefined : {
|
const workspaceDragProps = workspaceId === undefined ? undefined : {
|
||||||
start: () => { setWorkspaceDrag({ workspaceId, over: null }) },
|
start: () => { setWorkspaceDrag({ workspaceId, over: null }) },
|
||||||
active: workspaceDrag !== null,
|
end: () => { setWorkspaceDrag(null) },
|
||||||
marker: null,
|
}
|
||||||
hover: (half: 'before' | 'after') => {
|
const hoverWorkspace = workspaceId === undefined
|
||||||
|
? undefined
|
||||||
|
: (half: 'before' | 'after') => {
|
||||||
setWorkspaceDrag(active => active === null
|
setWorkspaceDrag(active => active === null
|
||||||
? active
|
? active
|
||||||
: { ...active, over: { id: workspaceId, half } })
|
: { ...active, over: { id: workspaceId, half } })
|
||||||
},
|
}
|
||||||
drop: (half: 'before' | 'after') => {
|
const dropWorkspace = workspaceId === undefined
|
||||||
|
? undefined
|
||||||
|
: (half: 'before' | 'after') => {
|
||||||
if (workspaceDrag === null) return
|
if (workspaceDrag === null) return
|
||||||
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
||||||
const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId
|
const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId
|
||||||
@@ -197,9 +216,7 @@ function SessionTree({
|
|||||||
insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => {
|
insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => {
|
||||||
console.warn('workspace reorder rejected:', reason)
|
console.warn('workspace reorder rejected:', reason)
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
end: () => { setWorkspaceDrag(null) },
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
// Group section: header row + expanded top-level session rows. The
|
// Group section: header row + expanded top-level session rows. The
|
||||||
// inter-group breathing room is the section's own margin
|
// inter-group breathing room is the section's own margin
|
||||||
@@ -211,6 +228,19 @@ function SessionTree({
|
|||||||
workspaceMarker === 'before' && css.workspaceDropBefore,
|
workspaceMarker === 'before' && css.workspaceDropBefore,
|
||||||
workspaceMarker === 'after' && css.workspaceDropAfter,
|
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
|
<ProjectRowItem
|
||||||
group={group}
|
group={group}
|
||||||
@@ -219,7 +249,7 @@ function SessionTree({
|
|||||||
if (group.expanded) {
|
if (group.expanded) {
|
||||||
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
||||||
}
|
}
|
||||||
setExpandedProjects(l => toggled(l, group.key))
|
setWorkspaceExpanded(group.key, !group.expanded)
|
||||||
}}
|
}}
|
||||||
onCreate={() => {
|
onCreate={() => {
|
||||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||||
@@ -458,6 +488,8 @@ export function WorkspaceBrowser({
|
|||||||
// A flat list has no single Workspace account to drag. Keep the stored
|
// A flat list has no single Workspace account to drag. Keep the stored
|
||||||
// grouped preference intact while presenting the flat list by recency.
|
// grouped preference intact while presenting the flat list by recency.
|
||||||
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
|
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
|
||||||
|
// HMR can retain the preceding view-store instance until the slot remounts.
|
||||||
|
const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION)
|
||||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||||
// does not silently drop an in-progress filter.
|
// does not silently drop an in-progress filter.
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -497,12 +529,14 @@ export function WorkspaceBrowser({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wide || !searchExpanded) return
|
if (!wide || !searchExpanded) return
|
||||||
const onPointerDown = (event: PointerEvent): void => {
|
const onClick = (event: MouseEvent): void => {
|
||||||
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
|
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
|
||||||
searchInput.current?.blur()
|
searchInput.current?.blur()
|
||||||
|
setQuery('')
|
||||||
|
setSearchExpanded(false)
|
||||||
}
|
}
|
||||||
document.addEventListener('pointerdown', onPointerDown)
|
document.addEventListener('click', onClick)
|
||||||
return () => { document.removeEventListener('pointerdown', onPointerDown) }
|
return () => { document.removeEventListener('click', onClick) }
|
||||||
}, [wide, searchExpanded])
|
}, [wide, searchExpanded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -810,6 +844,8 @@ export function WorkspaceBrowser({
|
|||||||
onSessionArchive={onSessionArchive}
|
onSessionArchive={onSessionArchive}
|
||||||
forkSession={forkSession}
|
forkSession={forkSession}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
|
workspaceExpansion={workspaceExpansion}
|
||||||
|
setWorkspaceExpanded={actions.setWorkspaceExpanded}
|
||||||
archivedSessionIds={archivedSessionIds}
|
archivedSessionIds={archivedSessionIds}
|
||||||
startSession={startSession}
|
startSession={startSession}
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export interface RowDragProps {
|
|||||||
end: () => void
|
end: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */
|
||||||
|
interface WorkspaceRowDragProps {
|
||||||
|
start: () => void
|
||||||
|
end: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/** Pointer-position half of a row (insert line above or below). */
|
/** Pointer-position half of a row (insert line above or below). */
|
||||||
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
@@ -108,7 +114,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
|
|||||||
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
|
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
|
||||||
actions?: { rename: () => void; delete: () => void } | undefined
|
actions?: { rename: () => void; delete: () => void } | undefined
|
||||||
/** Present only for real Workspace rows in the grouped view. */
|
/** Present only for real Workspace rows in the grouped view. */
|
||||||
drag?: RowDragProps | undefined
|
drag?: WorkspaceRowDragProps | undefined
|
||||||
t: RowTranslate
|
t: RowTranslate
|
||||||
}) {
|
}) {
|
||||||
const row = group
|
const row = group
|
||||||
@@ -122,10 +128,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
|
|||||||
]
|
]
|
||||||
const ownRow = (
|
const ownRow = (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
|
||||||
css.projectRow, menuOpen && css.menuOpen,
|
|
||||||
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
|
|
||||||
)}
|
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={row.expanded}
|
aria-expanded={row.expanded}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -138,21 +141,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
|
|||||||
drag.start()
|
drag.start()
|
||||||
}}
|
}}
|
||||||
onDragEnd={drag?.end}
|
onDragEnd={drag?.end}
|
||||||
onDragOver={drag === undefined
|
|
||||||
? undefined
|
|
||||||
: (e) => {
|
|
||||||
if (!drag.active) return
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = 'move'
|
|
||||||
drag.hover(rowHalf(e))
|
|
||||||
}}
|
|
||||||
onDrop={drag === undefined
|
|
||||||
? undefined
|
|
||||||
: (e) => {
|
|
||||||
if (!drag.active) return
|
|
||||||
e.preventDefault()
|
|
||||||
drag.drop(rowHalf(e))
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ export type WorkspaceGroupBy = 'workspace' | 'flat'
|
|||||||
/** Session order: durable Workspace order or a derived timestamp order. */
|
/** Session order: durable Workspace order or a derived timestamp order. */
|
||||||
export type WorkspaceOrderBy = 'manual' | 'created' | 'updated'
|
export type WorkspaceOrderBy = 'manual' | 'created' | 'updated'
|
||||||
|
|
||||||
/** Workspace browser viewing state; transient expansion facts stay component-local. */
|
/** Workspace browser viewing state persisted across surface remounts and reloads. */
|
||||||
type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy }
|
type WorkspaceViewState = {
|
||||||
|
groupBy: WorkspaceGroupBy
|
||||||
|
orderBy: WorkspaceOrderBy
|
||||||
|
/** Explicit zero-or-five-session state keyed by Workspace group identity. */
|
||||||
|
workspaceExpansion: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Annotation twin of the actions literal below (the export needs a declared
|
* Annotation twin of the actions literal below (the export needs a declared
|
||||||
@@ -22,6 +27,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy
|
|||||||
type WorkspaceViewActions = {
|
type WorkspaceViewActions = {
|
||||||
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
||||||
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
|
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
|
||||||
|
setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,12 +36,12 @@ type WorkspaceViewActions = {
|
|||||||
*/
|
*/
|
||||||
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
||||||
return defineStore({
|
return defineStore({
|
||||||
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }),
|
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }),
|
||||||
// The added order field changes the whole-value persistence format.
|
persist: 'dsh.workspace.view.v3',
|
||||||
persist: 'dsh.workspace.view.v2',
|
|
||||||
actions: {
|
actions: {
|
||||||
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
||||||
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
|
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
|
||||||
|
setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user