|
|
|
|
@@ -22,6 +22,7 @@ import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
|
|
|
|
import type { SessionNode, SessionOrderBy } from './tree.ts'
|
|
|
|
|
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
|
|
|
|
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
|
|
|
|
import { FLAT_SESSION_ORDER_KEY } from './stores.ts'
|
|
|
|
|
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
|
|
|
|
|
import css from './WorkspaceBrowser.module.css'
|
|
|
|
|
|
|
|
|
|
@@ -80,6 +81,46 @@ function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListStat
|
|
|
|
|
return a < b ? -1 : 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Reconcile one editable order account and apply its activity-promotion policy. */
|
|
|
|
|
function nextSessionOrderAccount({
|
|
|
|
|
sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency,
|
|
|
|
|
}: {
|
|
|
|
|
sessionIds: readonly SessionId[]
|
|
|
|
|
previousOrder: readonly string[] | undefined
|
|
|
|
|
previousUpdatedAt: Readonly<Record<string, number>>
|
|
|
|
|
list: SessionListState
|
|
|
|
|
orderBy: SessionOrderBy
|
|
|
|
|
sortByRecency: boolean
|
|
|
|
|
}): { order: SessionId[]; updatedAt: Record<string, number>; changed: boolean } {
|
|
|
|
|
let order = reconciledSessionOrder(sessionIds, previousOrder)
|
|
|
|
|
if (sortByRecency) {
|
|
|
|
|
order.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
|
|
|
|
} else if (orderBy === 'updated') {
|
|
|
|
|
const promoted = sessionIds
|
|
|
|
|
.filter((id) => {
|
|
|
|
|
const session = list.byId[id]
|
|
|
|
|
return session !== undefined
|
|
|
|
|
&& (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
|
|
|
|
if (promoted.length > 0) {
|
|
|
|
|
const promotedIds = new Set(promoted)
|
|
|
|
|
order = [...promoted, ...order.filter(id => !promotedIds.has(id))]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const updatedAt: Record<string, number> = {}
|
|
|
|
|
for (const id of sessionIds) {
|
|
|
|
|
const session = list.byId[id]
|
|
|
|
|
if (session !== undefined) updatedAt[id] = session.updatedAt
|
|
|
|
|
}
|
|
|
|
|
const orderChanged = previousOrder === undefined
|
|
|
|
|
|| order.length !== previousOrder.length
|
|
|
|
|
|| order.some((id, index) => id !== previousOrder[index])
|
|
|
|
|
const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length
|
|
|
|
|
|| Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp)
|
|
|
|
|
return { order, updatedAt, changed: orderChanged || timestampsChanged }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
|
|
|
|
|
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
|
|
|
|
groupBy: 'workspace' | 'flat'
|
|
|
|
|
@@ -97,8 +138,9 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
|
|
|
|
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
|
|
|
|
{ id: 'workspace', label: t('groupBy.workspace') },
|
|
|
|
|
{ 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') },
|
|
|
|
|
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
|
|
|
|
|
{ id: 'manual', label: t('orderBy.manual') },
|
|
|
|
|
{ id: 'updated', label: t('orderBy.updated') },
|
|
|
|
|
]}
|
|
|
|
|
selectedIds={[groupBy, orderBy]}
|
|
|
|
|
@@ -159,7 +201,7 @@ type SessionTreeProps = Pick<
|
|
|
|
|
workspaceExpansion: Readonly<Record<string, boolean>>
|
|
|
|
|
/** Persist one Workspace group's zero-or-five-session state. */
|
|
|
|
|
setWorkspaceExpanded: (key: string, expanded: boolean) => void
|
|
|
|
|
/** Shared editable orders used by both Session order modes. */
|
|
|
|
|
/** Shared editable orders used by Workspace groups and the flat-list account. */
|
|
|
|
|
recentSessionOrder: Readonly<Record<string, readonly string[]>>
|
|
|
|
|
/** Last update timestamps observed for one-time recent-update promotions. */
|
|
|
|
|
recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
|
|
|
|
|
@@ -246,34 +288,16 @@ function SessionTree({
|
|
|
|
|
for (const { key, sessionIds } of accounts) {
|
|
|
|
|
const previousOrder = recentSessionOrder[key]
|
|
|
|
|
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
|
|
|
|
|
let nextOrder = reconciledSessionOrder(sessionIds, previousOrder)
|
|
|
|
|
if (orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated)) {
|
|
|
|
|
nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
|
|
|
|
} else if (orderBy === 'updated') {
|
|
|
|
|
const promoted = sessionIds
|
|
|
|
|
.filter((id) => {
|
|
|
|
|
const session = list.byId[id]
|
|
|
|
|
return session !== undefined
|
|
|
|
|
&& (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
|
|
|
|
if (promoted.length > 0) {
|
|
|
|
|
const promotedIds = new Set(promoted)
|
|
|
|
|
nextOrder = [...promoted, ...nextOrder.filter(id => !promotedIds.has(id))]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const nextUpdatedAt: Record<string, number> = {}
|
|
|
|
|
for (const id of sessionIds) {
|
|
|
|
|
const session = list.byId[id]
|
|
|
|
|
if (session !== undefined) nextUpdatedAt[id] = session.updatedAt
|
|
|
|
|
}
|
|
|
|
|
const orderChanged = previousOrder === undefined
|
|
|
|
|
|| nextOrder.length !== previousOrder.length
|
|
|
|
|
|| nextOrder.some((id, index) => id !== previousOrder[index])
|
|
|
|
|
const timestampsChanged = Object.keys(nextUpdatedAt).length !== Object.keys(previousUpdatedAt).length
|
|
|
|
|
|| Object.entries(nextUpdatedAt).some(([id, updatedAt]) => previousUpdatedAt[id] !== updatedAt)
|
|
|
|
|
if (orderChanged || timestampsChanged) {
|
|
|
|
|
syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt)
|
|
|
|
|
const next = nextSessionOrderAccount({
|
|
|
|
|
sessionIds,
|
|
|
|
|
previousOrder,
|
|
|
|
|
previousUpdatedAt,
|
|
|
|
|
list,
|
|
|
|
|
orderBy,
|
|
|
|
|
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
|
|
|
|
|
})
|
|
|
|
|
if (next.changed) {
|
|
|
|
|
syncRecentSessions(key, next.order.map(id => id as string), next.updatedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
|
|
|
|
|
@@ -512,15 +536,90 @@ function SessionTree({
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
|
|
|
|
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
|
|
|
|
|
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
|
|
|
|
|
/** The flat "In one list" body: every session is one draggable top-level row. */
|
|
|
|
|
function FlatList({
|
|
|
|
|
useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
|
|
|
|
|
orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
|
|
|
|
|
}: Pick<
|
|
|
|
|
SessionTreeProps,
|
|
|
|
|
| 'useSessions'
|
|
|
|
|
| 'open'
|
|
|
|
|
| 'forkSession'
|
|
|
|
|
| 'onSessionRename'
|
|
|
|
|
| 'onSessionArchive'
|
|
|
|
|
| 'archivedSessionIds'
|
|
|
|
|
| 'orderBy'
|
|
|
|
|
| 'recentSessionOrder'
|
|
|
|
|
| 'recentSessionUpdatedAt'
|
|
|
|
|
| 'syncRecentSessions'
|
|
|
|
|
| 'setRecentSessionOrder'
|
|
|
|
|
| 't'
|
|
|
|
|
>) {
|
|
|
|
|
const list = useSessions(s => s)
|
|
|
|
|
const rows = useMemo(
|
|
|
|
|
const baseRows = useMemo(
|
|
|
|
|
() => deriveFlat(list, archivedSessionIds),
|
|
|
|
|
[list, archivedSessionIds],
|
|
|
|
|
)
|
|
|
|
|
const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
|
|
|
|
|
const previousOrderBy = useRef(orderBy)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (list.phase !== 'ready') return
|
|
|
|
|
const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY]
|
|
|
|
|
const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {}
|
|
|
|
|
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
|
|
|
|
|
previousOrderBy.current = orderBy
|
|
|
|
|
const next = nextSessionOrderAccount({
|
|
|
|
|
sessionIds,
|
|
|
|
|
previousOrder,
|
|
|
|
|
previousUpdatedAt,
|
|
|
|
|
list,
|
|
|
|
|
orderBy,
|
|
|
|
|
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
|
|
|
|
|
})
|
|
|
|
|
if (next.changed) {
|
|
|
|
|
syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
|
|
|
|
|
}
|
|
|
|
|
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions])
|
|
|
|
|
const rows = useMemo(() => {
|
|
|
|
|
const byId = new Map(baseRows.map(row => [row.id, row]))
|
|
|
|
|
return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY])
|
|
|
|
|
.flatMap((id) => {
|
|
|
|
|
const row = byId.get(id)
|
|
|
|
|
return row === undefined ? [] : [row]
|
|
|
|
|
})
|
|
|
|
|
}, [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])
|
|
|
|
|
const commitDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
|
|
|
|
|
if (dropCommitted.current) return
|
|
|
|
|
dropCommitted.current = true
|
|
|
|
|
setDrag(null)
|
|
|
|
|
const targetIndex = rows.findIndex(row => row.id === over.id)
|
|
|
|
|
if (targetIndex === -1) return
|
|
|
|
|
const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id
|
|
|
|
|
if (anchor === activeDrag.sessionId) return
|
|
|
|
|
const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId)
|
|
|
|
|
const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor)
|
|
|
|
|
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
|
|
|
const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId)
|
|
|
|
|
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
|
|
|
|
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
|
|
|
|
setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
|
|
|
|
|
}
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
return (
|
|
|
|
|
<div className={clsx(css.treeBody, css.wide)}>
|
|
|
|
|
@@ -528,19 +627,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
|
|
|
|
|
{rows.length === 0 && (
|
|
|
|
|
<div className={css.empty}>{t('empty.none')}</div>
|
|
|
|
|
)}
|
|
|
|
|
{rows.map(node => (
|
|
|
|
|
<SessionNodeItem
|
|
|
|
|
key={node.id}
|
|
|
|
|
node={node}
|
|
|
|
|
currentId={list.current}
|
|
|
|
|
now={now}
|
|
|
|
|
onOpen={open}
|
|
|
|
|
onRename={onSessionRename}
|
|
|
|
|
onFork={forkSession}
|
|
|
|
|
onArchive={onSessionArchive}
|
|
|
|
|
t={t}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
{rows.map((node) => {
|
|
|
|
|
const active = drag !== null
|
|
|
|
|
return (
|
|
|
|
|
<SessionNodeItem
|
|
|
|
|
key={node.id}
|
|
|
|
|
node={node}
|
|
|
|
|
currentId={list.current}
|
|
|
|
|
now={now}
|
|
|
|
|
onOpen={open}
|
|
|
|
|
onRename={onSessionRename}
|
|
|
|
|
onFork={forkSession}
|
|
|
|
|
onArchive={onSessionArchive}
|
|
|
|
|
flat
|
|
|
|
|
drag={{
|
|
|
|
|
start: () => {
|
|
|
|
|
dropCommitted.current = false
|
|
|
|
|
setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null })
|
|
|
|
|
},
|
|
|
|
|
active,
|
|
|
|
|
marker: active && drag.over?.id === node.id ? drag.over.half : null,
|
|
|
|
|
hover: (half) => {
|
|
|
|
|
setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } })
|
|
|
|
|
},
|
|
|
|
|
drop: (half) => {
|
|
|
|
|
if (drag !== null) commitDrag(drag, { id: node.id, half })
|
|
|
|
|
},
|
|
|
|
|
end: () => {
|
|
|
|
|
if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over)
|
|
|
|
|
else setDrag(null)
|
|
|
|
|
dropCommitted.current = false
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
t={t}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<span className={css.fade} />
|
|
|
|
|
</div>
|
|
|
|
|
@@ -654,9 +776,6 @@ export function WorkspaceBrowser({
|
|
|
|
|
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
|
|
|
|
|
const groupBy = useStore(s => s.groupBy)
|
|
|
|
|
const orderBy = useStore(s => s.orderBy)
|
|
|
|
|
// A flat list has no single Workspace account to drag. Keep the stored
|
|
|
|
|
// grouped preference intact while presenting the flat list by recency.
|
|
|
|
|
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
|
|
|
|
|
const workspaceExpansion = useStore(s => s.workspaceExpansion)
|
|
|
|
|
const recentSessionOrder = useStore(s => s.recentSessionOrder)
|
|
|
|
|
const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt)
|
|
|
|
|
@@ -664,6 +783,7 @@ export function WorkspaceBrowser({
|
|
|
|
|
if (workspacePhase !== 'ready') return
|
|
|
|
|
actions.retainWorkspaceKeys([
|
|
|
|
|
UNGROUPED_KEY,
|
|
|
|
|
FLAT_SESSION_ORDER_KEY,
|
|
|
|
|
...workspaces.map(workspace => workspace.workspaceId as string),
|
|
|
|
|
])
|
|
|
|
|
}, [actions.retainWorkspaceKeys, workspacePhase, workspaces])
|
|
|
|
|
@@ -928,7 +1048,7 @@ export function WorkspaceBrowser({
|
|
|
|
|
{wide && (
|
|
|
|
|
<ViewOptionsMenu
|
|
|
|
|
groupBy={groupBy}
|
|
|
|
|
orderBy={effectiveOrderBy}
|
|
|
|
|
orderBy={orderBy}
|
|
|
|
|
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
|
|
|
|
|
onOrderPick={(mode) => { actions.setOrderBy(mode) }}
|
|
|
|
|
t={t}
|
|
|
|
|
@@ -1011,7 +1131,13 @@ export function WorkspaceBrowser({
|
|
|
|
|
<FlatList
|
|
|
|
|
useSessions={useSessions} open={open} forkSession={forkSession}
|
|
|
|
|
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
|
|
|
|
archivedSessionIds={archivedSessionIds} t={t}
|
|
|
|
|
archivedSessionIds={archivedSessionIds}
|
|
|
|
|
orderBy={orderBy}
|
|
|
|
|
recentSessionOrder={recentSessionOrder}
|
|
|
|
|
recentSessionUpdatedAt={recentSessionUpdatedAt}
|
|
|
|
|
syncRecentSessions={actions.syncRecentSessions}
|
|
|
|
|
setRecentSessionOrder={actions.setRecentSessionOrder}
|
|
|
|
|
t={t}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
: (
|
|
|
|
|
|