276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
/**
|
|
* Collapse is a slide plus crossfade: content freezes at its expanded
|
|
* width (inline style) and fades out in place while the sliding column
|
|
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
|
|
* the wide-only content (brand, labels, input, tree) unmounts, dropping
|
|
* the sessions subscription, and the control rows snap to the 56px rail
|
|
* (one icon each, same top-down order) fading in as the slide ends. Rail
|
|
* search expands and focuses the search box.
|
|
*/
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
import clsx from 'clsx'
|
|
import {
|
|
BrandWordmark, FishLogo,
|
|
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
|
|
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
|
Menu, Tooltip,
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import type { SidebarRootComponentProps } from './contract/slots.ts'
|
|
import { deriveRows } from './tree.ts'
|
|
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
|
|
import css from './SidebarRoot.module.css'
|
|
|
|
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
|
|
const COLLAPSE_SETTLE_MS = 150
|
|
|
|
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
|
const EXPAND_SLIDE_MS = 300
|
|
|
|
const GROUP_BY_ITEMS = [
|
|
{ id: 'workspace', label: 'WorkSpace' },
|
|
// Only workspace grouping is implemented.
|
|
{ id: 'update', label: 'Update', disabled: true },
|
|
{ id: 'status', label: 'Status', disabled: true },
|
|
]
|
|
|
|
/** Immutable membership toggle for the local expansion arrays. */
|
|
function toggled(list: readonly string[], key: string): string[] {
|
|
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
|
|
}
|
|
|
|
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
|
function GroupByMenu() {
|
|
const [open, setOpen] = useState(false)
|
|
return (
|
|
<Menu
|
|
open={open}
|
|
onClose={() => { setOpen(false) }}
|
|
items={GROUP_BY_ITEMS}
|
|
selectedId="workspace"
|
|
onSelect={() => { setOpen(false) }}
|
|
align="end"
|
|
anchor={(
|
|
<button
|
|
type="button"
|
|
className={clsx(css.iconButton, css.wide)}
|
|
aria-label="Group by"
|
|
onClick={() => { setOpen((v) => !v) }}
|
|
>
|
|
<IconPersonalizationOutline16 />
|
|
</button>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
|
|
/** Live search filter owned by the root (the query outlives the tree). */
|
|
query: string
|
|
}
|
|
|
|
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
|
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
|
|
const list = useSessions((s) => s)
|
|
// Selection belongs to the sessions snapshot, not layout state.
|
|
const current = useSessions((s) => s.current)
|
|
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
|
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
|
const rows = useMemo(
|
|
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
|
|
[list, expandedProjects, expandedSessions, query],
|
|
)
|
|
const now = Date.now()
|
|
|
|
// Presentational lookup (not tree derivation): the group holding the
|
|
// selected session gets the active folder; only expanded groups can show it.
|
|
let activeGroup: string | undefined
|
|
if (current !== undefined) {
|
|
for (const row of rows) {
|
|
if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break }
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={clsx(css.treeBody, css.wide)}>
|
|
<div className={css.list} role="tree" aria-label="Sessions">
|
|
{rows.length === 0 && (
|
|
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
|
)}
|
|
{rows.map((row, i) => row.type === 'project'
|
|
? (
|
|
<Fragment key={`p:${row.key}`}>
|
|
{/* Batch separator: a project row closing an expanded session run (figma 133:7661). */}
|
|
{i > 0 && rows[i - 1]!.type === 'session' && <span className={css.batchGap} />}
|
|
<ProjectRowItem
|
|
row={row}
|
|
active={row.key === activeGroup}
|
|
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
|
|
onCreate={() => { onCreate(row.cwd) }}
|
|
/>
|
|
</Fragment>
|
|
)
|
|
: (
|
|
<SessionRowItem
|
|
key={row.id}
|
|
row={row}
|
|
selected={row.id === current}
|
|
now={now}
|
|
onOpen={() => { onOpen(row.id) }}
|
|
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className={css.fade} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Render the sidebar column.
|
|
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
|
* @returns the sidebar element tree.
|
|
*/
|
|
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
|
|
// The query outlives the tree and the input (both wide-only) so collapsing
|
|
// does not silently drop an in-progress filter.
|
|
const [query, setQuery] = useState('')
|
|
const searchInput = useRef<HTMLInputElement | null>(null)
|
|
|
|
// Wide content stays mounted while the collapse animates (fading via
|
|
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
|
|
const [settled, setSettled] = useState(collapsed)
|
|
useEffect(() => {
|
|
if (!collapsed) { setSettled(false); return }
|
|
const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
|
|
return () => { window.clearTimeout(timer) }
|
|
}, [collapsed])
|
|
const wide = !collapsed || !settled
|
|
|
|
// Freeze the content at its expanded width while it fades out (collapsed
|
|
// && wide): the sliding column then clips it instead of reflowing it. The
|
|
// rail layout (.collapsed styles) only applies once the fade settles.
|
|
const lastWideWidth = useRef(width)
|
|
if (!collapsed) lastWideWidth.current = width
|
|
|
|
// Rail-in only crossfades a live collapse: a refresh straight into the
|
|
// collapsed state renders the rail statically (no delay-hidden icons).
|
|
const everWide = useRef(!collapsed)
|
|
if (!collapsed) everWide.current = true
|
|
|
|
// Rail search = expand + land in the search box: the flag arms before the
|
|
// expand toggle; once expanded the input is mounted and takes focus.
|
|
const [searchOnExpand, setSearchOnExpand] = useState(false)
|
|
useEffect(() => {
|
|
if (!collapsed && searchOnExpand) {
|
|
const timer = window.setTimeout(() => {
|
|
searchInput.current?.focus({ preventScroll: true })
|
|
setSearchOnExpand(false)
|
|
}, EXPAND_SLIDE_MS)
|
|
return () => { window.clearTimeout(timer) }
|
|
}
|
|
}, [collapsed, searchOnExpand])
|
|
|
|
return (
|
|
<div
|
|
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
|
|
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
|
|
>
|
|
<div className={css.logoRow}>
|
|
{wide && (
|
|
<span className={clsx(css.brand, css.wide)}>
|
|
<BrandWordmark />
|
|
</span>
|
|
)}
|
|
{/* Rail resting state is the whale mark; hovering swaps in the panel
|
|
icon (the expand affordance, figma sidebar-hover flow). */}
|
|
<Tooltip label="Open sidebar" disabled={wide}>
|
|
<button
|
|
type="button"
|
|
className={clsx(css.iconButton, css.toggle)}
|
|
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
|
|
onClick={() => { onToggleSidebar() }}
|
|
>
|
|
{!wide && <FishLogo className={css.railFish} size={24} />}
|
|
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
|
|
<IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<Tooltip label="New session" disabled={wide}>
|
|
<button
|
|
type="button"
|
|
className={css.newSession}
|
|
aria-label="New session"
|
|
onClick={() => { onCreate() }}
|
|
>
|
|
<IconNewChatOutline16 size={wide ? 14 : 18} />
|
|
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
|
</button>
|
|
</Tooltip>
|
|
|
|
<div className={css.sectionHeader}>
|
|
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
|
|
{wide && <GroupByMenu />}
|
|
<Tooltip label="New Workspace" disabled={wide}>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label="New workspace"
|
|
onClick={() => { onCreate() }}
|
|
>
|
|
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
|
decorative). Collapsed: the icon is the rail's search control. */}
|
|
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
|
|
<Tooltip label="Search" disabled={wide}>
|
|
<button
|
|
type="button"
|
|
className={css.searchButton}
|
|
aria-label="Search sessions"
|
|
tabIndex={collapsed ? 0 : -1}
|
|
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
|
|
>
|
|
<IconSearchOutline16 size={wide ? 14 : 18} />
|
|
</button>
|
|
</Tooltip>
|
|
{wide && (
|
|
<input
|
|
ref={searchInput}
|
|
className={clsx(css.searchInput, css.wide)}
|
|
type="text"
|
|
placeholder="Search name, keywords..."
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value) }}
|
|
/>
|
|
)}
|
|
{wide && query !== '' && (
|
|
<button
|
|
type="button"
|
|
className={clsx(css.clearButton, css.wide)}
|
|
aria-label="Clear search"
|
|
onClick={() => { setQuery('') }}
|
|
>
|
|
<IconCloseFill14 />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
|
|
both states while the tree itself is wide-only. */}
|
|
<div className={css.listArea}>
|
|
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
|
|
</div>
|
|
|
|
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
|
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
|
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|