@@ -1,4 +1,4 @@ |
| 1 | | -import { memo, useEffect, useMemo, useRef, useState } from "react"; |
| 1 | +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 2 | import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; |
| 3 | 3 | |
| 4 | 4 | import { PaneHeader } from "@/components/panes/PaneHeader"; |
@@ -6,19 +6,11 @@ import { relativeTime, shortEntrypoint, shortModel, tildeify } from "@/lib/forma |
| 6 | 6 | import { useSessionStore } from "@/lib/store/sessions"; |
| 7 | 7 | import type { Project, SessionSummary } from "@/lib/ipc/types"; |
| 8 | 8 | |
| 9 | | -// --------------------------------------------------------------- |
| 10 | | -// Virtualized flat-row model |
| 11 | | -// --------------------------------------------------------------- |
| 12 | | -// |
| 13 | | -// The previous implementation was a straight `projects.map(...)` |
| 14 | | -// nested inside section wrappers. That worked for small session |
| 15 | | -// sets but beachballed once the user had hundreds of sessions — |
| 16 | | -// every store update re-reconciled every row. We now flatten the |
| 17 | | -// tree into a single list of typed rows and feed it to react- |
| 18 | | -// virtuoso, which only mounts rows currently in the viewport (plus |
| 19 | | -// a small overscan buffer). Rescans that return an equal-by-key |
| 20 | | -// row skip re-rendering thanks to the `React.memo` row renderer. |
| 21 | | - |
| 9 | +// Flat-row virtualization model for the sidebar. The previous tree |
| 10 | +// walk reconciled every row on every store tick; virtualizing |
| 11 | +// bounds the live row count to the viewport and a small overscan |
| 12 | +// buffer, and memoized row components mean rescans that return |
| 13 | +// equal-by-key rows don't re-render. |
| 22 | 14 | type ProjectsRow = |
| 23 | 15 | | { |
| 24 | 16 | kind: "project-header"; |
@@ -138,23 +130,18 @@ function buildRows( |
| 138 | 130 | return rows; |
| 139 | 131 | } |
| 140 | 132 | |
| 141 | | -// --------------------------------------------------------------- |
| 142 | | -// Top-level pane |
| 143 | | -// --------------------------------------------------------------- |
| 144 | | - |
| 145 | 133 | export function ProjectsPane() { |
| 146 | 134 | const projects = useSessionStore((s) => s.projects); |
| 147 | 135 | const loading = useSessionStore((s) => s.loading.projects); |
| 148 | 136 | const expandedProjectIds = useSessionStore((s) => s.expandedProjectIds); |
| 149 | 137 | const selectedSessionId = useSessionStore((s) => s.selectedSessionId); |
| 150 | | - const toggleProject = useSessionStore((s) => s.toggleProject); |
| 151 | | - const selectSession = useSessionStore((s) => s.selectSession); |
| 152 | 138 | const rescan = useSessionStore((s) => s.rescan); |
| 153 | 139 | |
| 154 | 140 | const [observerExpanded, setObserverExpanded] = useState(false); |
| 155 | 141 | const [archiveExpanded, setArchiveExpanded] = useState(false); |
| 156 | 142 | |
| 157 | 143 | const virtuosoRef = useRef<VirtuosoHandle | null>(null); |
| 144 | + const rowsRef = useRef<ProjectsRow[]>([]); |
| 158 | 145 | |
| 159 | 146 | const rows = useMemo( |
| 160 | 147 | () => |
@@ -167,27 +154,28 @@ export function ProjectsPane() { |
| 167 | 154 | ), |
| 168 | 155 | [projects, expandedProjectIds, observerExpanded, archiveExpanded, loading], |
| 169 | 156 | ); |
| 157 | + rowsRef.current = rows; |
| 170 | 158 | |
| 171 | | - const regularCount = useMemo( |
| 172 | | - () => projects.filter((p) => p.category === "regular").length, |
| 173 | | - [projects], |
| 174 | | - ); |
| 175 | | - const totalSessionCount = useMemo( |
| 176 | | - () => |
| 177 | | - projects |
| 178 | | - .filter((p) => p.category === "regular") |
| 179 | | - .reduce((acc, p) => acc + p.sessionCount, 0), |
| 180 | | - [projects], |
| 181 | | - ); |
| 159 | + // Single pass over projects for the header subtitle. |
| 160 | + const { regularCount, totalSessionCount } = useMemo(() => { |
| 161 | + let count = 0; |
| 162 | + let total = 0; |
| 163 | + for (const p of projects) { |
| 164 | + if (p.category === "regular") { |
| 165 | + count += 1; |
| 166 | + total += p.sessionCount; |
| 167 | + } |
| 168 | + } |
| 169 | + return { regularCount: count, totalSessionCount: total }; |
| 170 | + }, [projects]); |
| 182 | 171 | |
| 183 | | - // Scroll the selected session into view when it changes out-of- |
| 184 | | - // band (e.g. from the titlebar "N terminals" popover or from a |
| 185 | | - // deep link). If the row isn't currently in the flat list |
| 186 | | - // because its parent project is collapsed, the scroll is a no-op |
| 187 | | - // — still correct, just silent. |
| 172 | + // Scroll the selected session into view only when the selection |
| 173 | + // itself changes — the previous version fired on every rescan |
| 174 | + // because `rows` was in its dep array, which yanked the viewport |
| 175 | + // back to the selected row every 2s as the user scrolled away. |
| 188 | 176 | useEffect(() => { |
| 189 | 177 | if (!selectedSessionId) return; |
| 190 | | - const idx = rows.findIndex( |
| 178 | + const idx = rowsRef.current.findIndex( |
| 191 | 179 | (r) => r.kind === "session-row" && r.session.id === selectedSessionId, |
| 192 | 180 | ); |
| 193 | 181 | if (idx >= 0) { |
@@ -197,7 +185,24 @@ export function ProjectsPane() { |
| 197 | 185 | behavior: "auto", |
| 198 | 186 | }); |
| 199 | 187 | } |
| 200 | | - }, [selectedSessionId, rows]); |
| 188 | + }, [selectedSessionId]); |
| 189 | + |
| 190 | + const toggleObserver = useCallback( |
| 191 | + () => setObserverExpanded((x) => !x), |
| 192 | + [], |
| 193 | + ); |
| 194 | + const toggleArchive = useCallback(() => setArchiveExpanded((x) => !x), []); |
| 195 | + |
| 196 | + const renderItem = useCallback( |
| 197 | + (_index: number, row: ProjectsRow) => ( |
| 198 | + <RowRenderer |
| 199 | + row={row} |
| 200 | + onToggleObserver={toggleObserver} |
| 201 | + onToggleArchive={toggleArchive} |
| 202 | + /> |
| 203 | + ), |
| 204 | + [toggleObserver, toggleArchive], |
| 205 | + ); |
| 201 | 206 | |
| 202 | 207 | return ( |
| 203 | 208 | <div className="flex h-full flex-col overflow-hidden"> |
@@ -226,40 +231,21 @@ export function ProjectsPane() { |
| 226 | 231 | data={rows} |
| 227 | 232 | computeItemKey={(_index, row) => row.key} |
| 228 | 233 | increaseViewportBy={{ top: 400, bottom: 400 }} |
| 229 | | - itemContent={(_index, row) => ( |
| 230 | | - <RowRenderer |
| 231 | | - row={row} |
| 232 | | - selectedSessionId={selectedSessionId} |
| 233 | | - onToggleProject={toggleProject} |
| 234 | | - onSelectSession={selectSession} |
| 235 | | - onToggleObserver={() => setObserverExpanded((x) => !x)} |
| 236 | | - onToggleArchive={() => setArchiveExpanded((x) => !x)} |
| 237 | | - /> |
| 238 | | - )} |
| 234 | + itemContent={renderItem} |
| 239 | 235 | /> |
| 240 | 236 | </div> |
| 241 | 237 | </div> |
| 242 | 238 | ); |
| 243 | 239 | } |
| 244 | 240 | |
| 245 | | -// --------------------------------------------------------------- |
| 246 | | -// Row renderer (memoized) |
| 247 | | -// --------------------------------------------------------------- |
| 248 | | - |
| 249 | 241 | interface RowRendererProps { |
| 250 | 242 | row: ProjectsRow; |
| 251 | | - selectedSessionId: string | null; |
| 252 | | - onToggleProject: (id: string) => void; |
| 253 | | - onSelectSession: (session: SessionSummary) => Promise<void>; |
| 254 | 243 | onToggleObserver: () => void; |
| 255 | 244 | onToggleArchive: () => void; |
| 256 | 245 | } |
| 257 | 246 | |
| 258 | 247 | const RowRenderer = memo(function RowRenderer({ |
| 259 | 248 | row, |
| 260 | | - selectedSessionId, |
| 261 | | - onToggleProject, |
| 262 | | - onSelectSession, |
| 263 | 249 | onToggleObserver, |
| 264 | 250 | onToggleArchive, |
| 265 | 251 | }: RowRendererProps) { |
@@ -271,18 +257,10 @@ const RowRenderer = memo(function RowRenderer({ |
| 271 | 257 | expanded={row.expanded} |
| 272 | 258 | muted={row.muted} |
| 273 | 259 | allowNewSession={row.allowNewSession} |
| 274 | | - onToggle={() => onToggleProject(row.project.id)} |
| 275 | 260 | /> |
| 276 | 261 | ); |
| 277 | 262 | case "session-row": |
| 278 | | - return ( |
| 279 | | - <SessionRowInner |
| 280 | | - session={row.session} |
| 281 | | - selected={row.session.id === selectedSessionId} |
| 282 | | - muted={row.muted} |
| 283 | | - onSelect={() => void onSelectSession(row.session)} |
| 284 | | - /> |
| 285 | | - ); |
| 263 | + return <SessionRowInner session={row.session} muted={row.muted} />; |
| 286 | 264 | case "section-header": |
| 287 | 265 | return ( |
| 288 | 266 | <SectionHeaderRow |
@@ -300,26 +278,20 @@ const RowRenderer = memo(function RowRenderer({ |
| 300 | 278 | } |
| 301 | 279 | }); |
| 302 | 280 | |
| 303 | | -// --------------------------------------------------------------- |
| 304 | | -// Row components (all memoized — re-render only when their props |
| 305 | | -// actually change, which for rescans of unchanged rows is never). |
| 306 | | -// --------------------------------------------------------------- |
| 307 | | - |
| 308 | 281 | const ProjectHeaderRow = memo(function ProjectHeaderRow({ |
| 309 | 282 | project, |
| 310 | 283 | expanded, |
| 311 | 284 | muted, |
| 312 | 285 | allowNewSession, |
| 313 | | - onToggle, |
| 314 | 286 | }: { |
| 315 | 287 | project: Project; |
| 316 | 288 | expanded: boolean; |
| 317 | 289 | muted: boolean; |
| 318 | 290 | allowNewSession: boolean; |
| 319 | | - onToggle: () => void; |
| 320 | 291 | }) { |
| 321 | 292 | const hasSessions = project.sessions.length > 0; |
| 322 | 293 | const beginNewSession = useSessionStore((s) => s.beginNewSession); |
| 294 | + const toggleProject = useSessionStore((s) => s.toggleProject); |
| 323 | 295 | // Narrow selector — only re-render when THIS project's live-pty |
| 324 | 296 | // rollup flips, not on every unrelated ptyIds mutation. |
| 325 | 297 | const hasChildPty = useSessionStore((s) => { |
@@ -333,7 +305,7 @@ const ProjectHeaderRow = memo(function ProjectHeaderRow({ |
| 333 | 305 | <div className="relative flex items-stretch"> |
| 334 | 306 | <button |
| 335 | 307 | type="button" |
| 336 | | - onClick={onToggle} |
| 308 | + onClick={() => toggleProject(project.id)} |
| 337 | 309 | className="flex w-full items-start gap-1.5 px-2 py-1.5 pr-8 text-left transition hover:bg-bg-1" |
| 338 | 310 | > |
| 339 | 311 | <span className="mt-0.5 w-3 shrink-0 text-[10px] text-fg-3"> |
@@ -388,19 +360,17 @@ const ProjectHeaderRow = memo(function ProjectHeaderRow({ |
| 388 | 360 | |
| 389 | 361 | const SessionRowInner = memo(function SessionRowInner({ |
| 390 | 362 | session, |
| 391 | | - selected, |
| 392 | 363 | muted, |
| 393 | | - onSelect, |
| 394 | 364 | }: { |
| 395 | 365 | session: SessionSummary; |
| 396 | | - selected: boolean; |
| 397 | 366 | muted: boolean; |
| 398 | | - onSelect: () => void; |
| 399 | 367 | }) { |
| 400 | | - // Narrow selector — zustand diffs the returned boolean, so this |
| 401 | | - // only re-renders when *this* row's live-PTY state flips. |
| 368 | + // Narrow selectors — only re-render when the row's own pty/selected |
| 369 | + // state actually flips, not on every unrelated store mutation. |
| 402 | 370 | const hasLivePty = useSessionStore((s) => s.ptyIds.has(session.id)); |
| 371 | + const selected = useSessionStore((s) => s.selectedSessionId === session.id); |
| 403 | 372 | const closeSessionPty = useSessionStore((s) => s.closeSessionPty); |
| 373 | + const selectSession = useSessionStore((s) => s.selectSession); |
| 404 | 374 | return ( |
| 405 | 375 | <div |
| 406 | 376 | className={`group/session relative ml-[14px] border-l border-border/60 ${ |
@@ -409,7 +379,7 @@ const SessionRowInner = memo(function SessionRowInner({ |
| 409 | 379 | > |
| 410 | 380 | <button |
| 411 | 381 | type="button" |
| 412 | | - onClick={onSelect} |
| 382 | + onClick={() => void selectSession(session)} |
| 413 | 383 | className={`flex w-full flex-col gap-0.5 border-l-2 py-1.5 pl-3 pr-8 text-left transition ${ |
| 414 | 384 | selected |
| 415 | 385 | ? "border-accent bg-bg-2" |