mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
605 lines
20 KiB
TypeScript
605 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
import { ChevronRight, Plus } from "lucide-react";
|
|
import { Accordion } from "@base-ui/react/accordion";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useDroppable,
|
|
useSensor,
|
|
useSensors,
|
|
type DragStartEvent,
|
|
type DragEndEvent,
|
|
type DragOverEvent,
|
|
} from "@dnd-kit/core";
|
|
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
|
import { Virtuoso } from "react-virtuoso";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import type { Issue, IssueStatus, Project } from "@multica/core/types";
|
|
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
|
import type { IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
|
|
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
|
import { StatusHeading } from "./status-heading";
|
|
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
|
|
import { useDragSettle } from "./use-drag-settle";
|
|
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
|
import { useT } from "../../i18n";
|
|
import {
|
|
type DragMoveUpdates,
|
|
makeKanbanCollision,
|
|
statusGroupId,
|
|
buildColumns,
|
|
computePosition,
|
|
findColumn,
|
|
insertIdByPosition,
|
|
issueMatchesGroup,
|
|
getMoveUpdates,
|
|
} from "../utils/drag-utils";
|
|
import type { BoardColumnGroup } from "./board-column";
|
|
import { useIssueSurfaceSelection } from "../surface/selection-context";
|
|
import type { IssueCreateDefaults } from "../surface/types";
|
|
import { VirtuosoSeed, VIRTUOSO_SEED_COUNT } from "../../common/virtuoso-seed";
|
|
import { DeferredTooltip } from "../../common/deferred-tooltip";
|
|
import { useRestoredScrollRef } from "../../platform";
|
|
|
|
// List rows are a fixed 36px (h-9). Sharing the estimate between the seed's
|
|
// trailing spacer and Virtuoso's defaultItemHeight keeps the shared
|
|
// scroller's height truthful from the first frame — which both stops the
|
|
// scrollbar from re-drawing across the seed → Virtuoso handoff and lets the
|
|
// restored scrollTop assignment stick at ref-attach (MUL-4741).
|
|
const LIST_ROW_ESTIMATED_HEIGHT = 36;
|
|
|
|
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
|
|
const EMPTY_IDS: string[] = [];
|
|
// Passed to <Virtuoso components> when there is no Footer. Must be a STABLE
|
|
// object, never `undefined`: react-virtuoso seeds `components` with an internal
|
|
// `{}` default, and an explicit `undefined` prop overwrites that default, so
|
|
// its startup destructure of `EmptyPlaceholder`/`Footer` throws (MUL-4474).
|
|
const EMPTY_VIRTUOSO_COMPONENTS = {};
|
|
|
|
function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] {
|
|
return visibleStatuses.map((status) => ({
|
|
id: statusGroupId(status),
|
|
title: status,
|
|
status,
|
|
createData: { status },
|
|
}));
|
|
}
|
|
|
|
function ListViewImpl({
|
|
issues,
|
|
visibleStatuses,
|
|
childProgressMap = EMPTY_PROGRESS_MAP,
|
|
projectMap,
|
|
myIssuesScope,
|
|
myIssuesFilter,
|
|
projectId,
|
|
onMoveIssue,
|
|
onCreateIssue,
|
|
sort,
|
|
}: {
|
|
issues: Issue[];
|
|
visibleStatuses: IssueStatus[];
|
|
childProgressMap?: Map<string, ChildProgress>;
|
|
projectMap?: Map<string, Project>;
|
|
myIssuesScope?: string;
|
|
myIssuesFilter?: MyIssuesFilter;
|
|
projectId?: string;
|
|
onMoveIssue?: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
|
|
onCreateIssue?: (defaults: IssueCreateDefaults) => void;
|
|
sort?: IssueSortParam;
|
|
}) {
|
|
const listCollapsedStatuses = useViewStore(
|
|
(s) => s.listCollapsedStatuses
|
|
);
|
|
const toggleListCollapsed = useViewStore(
|
|
(s) => s.toggleListCollapsed
|
|
);
|
|
const sortBy = useViewStore((s) => s.sortBy);
|
|
const { t } = useT("issues");
|
|
|
|
const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
|
|
const sortLabel = sortBy !== "position"
|
|
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
|
|
: null;
|
|
|
|
const expandedStatuses = useMemo(
|
|
() =>
|
|
visibleStatuses.filter(
|
|
(s) => !listCollapsedStatuses.includes(s)
|
|
),
|
|
[visibleStatuses, listCollapsedStatuses]
|
|
);
|
|
|
|
const myIssuesOpts = myIssuesScope
|
|
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
|
|
: undefined;
|
|
|
|
const dragEnabled = !!onMoveIssue;
|
|
|
|
const groups = useMemo(
|
|
() => buildListGroups(visibleStatuses),
|
|
[visibleStatuses],
|
|
);
|
|
const groupIds = useMemo(
|
|
() => new Set(groups.map((g) => g.id)),
|
|
[groups],
|
|
);
|
|
const groupMap = useMemo(
|
|
() => new Map(groups.map((g) => [g.id, g])),
|
|
[groups],
|
|
);
|
|
|
|
// --- Drag state ---
|
|
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
|
// Shared drag/settle primitive (see use-drag-settle) — same machine as
|
|
// board-view, so the two surfaces can't drift apart.
|
|
const {
|
|
columns,
|
|
setColumns,
|
|
columnsRef,
|
|
isDraggingRef,
|
|
isSettlingRef,
|
|
recentlyMovedRef,
|
|
settleVersion,
|
|
beginSettle,
|
|
} = useDragSettle(() => buildColumns(issues, groups, "status"));
|
|
|
|
useEffect(() => {
|
|
if (!isDraggingRef.current && !isSettlingRef.current) {
|
|
setColumns(buildColumns(issues, groups, "status"));
|
|
}
|
|
}, [issues, groups, settleVersion, setColumns, isDraggingRef, isSettlingRef]);
|
|
|
|
const issueMap = useMemo(() => {
|
|
const map = new Map<string, Issue>();
|
|
for (const issue of issues) map.set(issue.id, issue);
|
|
return map;
|
|
}, [issues]);
|
|
|
|
const issueMapRef = useRef(issueMap);
|
|
if (!isDraggingRef.current && !isSettlingRef.current) {
|
|
issueMapRef.current = issueMap;
|
|
}
|
|
|
|
const collisionDetection = useMemo(
|
|
() => makeKanbanCollision(groupIds),
|
|
[groupIds],
|
|
);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 5 },
|
|
})
|
|
);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
isDraggingRef.current = true;
|
|
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
|
setActiveIssue(issue);
|
|
},
|
|
[isDraggingRef],
|
|
);
|
|
|
|
const handleDragOver = useCallback(
|
|
(event: DragOverEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || recentlyMovedRef.current) return;
|
|
|
|
const activeId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
setColumns((prev) => {
|
|
const activeCol = findColumn(prev, activeId, groupIds);
|
|
const overCol = findColumn(prev, overId, groupIds);
|
|
if (!activeCol || !overCol || activeCol === overCol) return prev;
|
|
|
|
if (sortBy !== "position") return prev;
|
|
|
|
recentlyMovedRef.current = true;
|
|
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
|
const newIds = [...prev[overCol]!];
|
|
const overIndex = newIds.indexOf(overId);
|
|
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
|
newIds.splice(insertIndex, 0, activeId);
|
|
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
|
});
|
|
},
|
|
[groupIds, sortBy, recentlyMovedRef, setColumns],
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
isDraggingRef.current = false;
|
|
setActiveIssue(null);
|
|
|
|
const resetColumns = () =>
|
|
setColumns(buildColumns(issues, groups, "status"));
|
|
|
|
if (!over || !onMoveIssue) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
|
|
const activeId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
const cols = columnsRef.current;
|
|
const activeCol = findColumn(cols, activeId, groupIds);
|
|
const overCol = findColumn(cols, overId, groupIds);
|
|
if (!activeCol || !overCol) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
|
|
let finalColumns = cols;
|
|
if (activeCol === overCol && sortBy === "position") {
|
|
const ids = cols[activeCol]!;
|
|
const oldIndex = ids.indexOf(activeId);
|
|
const newIndex = ids.indexOf(overId);
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
const reordered = arrayMove(ids, oldIndex, newIndex);
|
|
finalColumns = { ...cols, [activeCol]: reordered };
|
|
setColumns(finalColumns);
|
|
}
|
|
}
|
|
|
|
const finalCol = sortBy === "position"
|
|
? findColumn(finalColumns, activeId, groupIds)
|
|
: overCol;
|
|
if (!finalCol) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
const finalGroup = groupMap.get(finalCol);
|
|
if (!finalGroup) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
|
|
const map = issueMapRef.current;
|
|
|
|
if (sortBy !== "position") {
|
|
const currentIssue = map.get(activeId);
|
|
if (!currentIssue || issueMatchesGroup(currentIssue, finalGroup)) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
// Optimistically move the row into the target group *now*. Without this
|
|
// the sortBy != "position" branch never touched local columns on drop,
|
|
// so the row sat in its origin group for the whole request and only
|
|
// jumped across when the mutation settled — the same "snaps back, then
|
|
// moves" glitch the board view had. Placement mirrors the cache
|
|
// (insertIdByPosition) so the settle rebuild is a visual no-op.
|
|
setColumns((prev) => {
|
|
const fromIds = (prev[activeCol] ?? []).filter((cid) => cid !== activeId);
|
|
const toIds = insertIdByPosition(
|
|
prev[finalCol] ?? [],
|
|
activeId,
|
|
currentIssue.position,
|
|
map,
|
|
);
|
|
return { ...prev, [activeCol]: fromIds, [finalCol]: toIds };
|
|
});
|
|
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), beginSettle());
|
|
return;
|
|
}
|
|
|
|
const finalIds = finalColumns[finalCol]!;
|
|
const newPosition = computePosition(finalIds, activeId, map);
|
|
const currentIssue = map.get(activeId);
|
|
|
|
if (
|
|
currentIssue &&
|
|
issueMatchesGroup(currentIssue, finalGroup) &&
|
|
currentIssue.position === newPosition
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// beginSettle() also bumps settleVersion on settle (board-view did, this
|
|
// branch did not) so a failed position move reverts instead of stranding
|
|
// the row at the drop target.
|
|
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), beginSettle());
|
|
},
|
|
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, setColumns, columnsRef, isDraggingRef],
|
|
);
|
|
|
|
// The single scroll container is shared by every status panel's Virtuoso as
|
|
// its customScrollParent, so a callback ref hands the element to the panels
|
|
// once it mounts. Keeping one scroller (rather than one per panel) preserves
|
|
// the current sticky-header + cross-section scroll behavior; only the rows
|
|
// inside each expanded panel virtualize.
|
|
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
|
// Pull-based scroll restoration (MUL-4741): assign the saved offset when
|
|
// the shared scroller attaches — the per-status seeds plus their estimate
|
|
// spacers give it a truthful height on the first commit.
|
|
const restoreScrollRef = useRestoredScrollRef("list");
|
|
const attachScroller = useCallback(
|
|
(el: HTMLDivElement | null) => {
|
|
setScrollEl(el);
|
|
restoreScrollRef(el);
|
|
},
|
|
[restoreScrollRef],
|
|
);
|
|
|
|
const content = (
|
|
<Accordion.Root
|
|
multiple
|
|
className="space-y-1"
|
|
value={expandedStatuses}
|
|
onValueChange={(value: string[]) => {
|
|
if (isDraggingRef.current) return;
|
|
for (const status of visibleStatuses) {
|
|
const wasExpanded = expandedStatuses.includes(status);
|
|
const isExpanded = value.includes(status);
|
|
if (wasExpanded !== isExpanded) {
|
|
toggleListCollapsed(status as IssueStatus);
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{visibleStatuses.map((status) => {
|
|
const isExpanded = expandedStatuses.includes(status);
|
|
return (
|
|
<StatusAccordionItem
|
|
key={status}
|
|
status={status}
|
|
issueIds={columns[statusGroupId(status)] ?? EMPTY_IDS}
|
|
issueMap={issueMapRef.current}
|
|
childProgressMap={childProgressMap}
|
|
projectMap={projectMap}
|
|
myIssuesOpts={myIssuesOpts}
|
|
projectId={projectId}
|
|
onCreateIssue={onCreateIssue}
|
|
dragEnabled={dragEnabled}
|
|
isExpanded={isExpanded}
|
|
sortLabel={sortLabel}
|
|
sort={sort}
|
|
scrollParent={scrollEl}
|
|
/>
|
|
);
|
|
})}
|
|
</Accordion.Root>
|
|
);
|
|
|
|
if (!dragEnabled) {
|
|
return (
|
|
<div ref={attachScroller} data-tab-scroll-root="list" className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={collisionDetection}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div ref={attachScroller} data-tab-scroll-root="list" className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
|
{content}
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={null}>
|
|
{activeIssue ? (
|
|
<div className="max-w-2xl rotate-1 cursor-grabbing opacity-90 shadow-lg shadow-black/10 rounded-md border border-border bg-card px-4 py-2">
|
|
<span className="text-xs text-muted-foreground mr-2">{activeIssue.identifier}</span>
|
|
<span className="text-sm">{activeIssue.title}</span>
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|
|
|
|
function StatusAccordionItem({
|
|
status,
|
|
issueIds,
|
|
issueMap,
|
|
childProgressMap,
|
|
projectMap,
|
|
myIssuesOpts,
|
|
projectId,
|
|
onCreateIssue,
|
|
dragEnabled,
|
|
isExpanded,
|
|
sortLabel,
|
|
sort,
|
|
scrollParent,
|
|
}: {
|
|
status: IssueStatus;
|
|
issueIds: string[];
|
|
issueMap: Map<string, Issue>;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
projectMap?: Map<string, Project>;
|
|
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
|
projectId?: string;
|
|
onCreateIssue?: (defaults: IssueCreateDefaults) => void;
|
|
dragEnabled: boolean;
|
|
isExpanded: boolean;
|
|
sortLabel: string | null;
|
|
sort?: IssueSortParam;
|
|
scrollParent: HTMLElement | null;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const selection = useIssueSurfaceSelection();
|
|
const selectedIds = selection.selectedIds;
|
|
const select = selection.select;
|
|
const deselect = selection.deselect;
|
|
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
|
|
status,
|
|
myIssuesOpts,
|
|
sort,
|
|
);
|
|
|
|
const issues = useMemo(
|
|
() => issueIds.flatMap((id) => {
|
|
const issue = issueMap.get(id);
|
|
return issue ? [issue] : [];
|
|
}),
|
|
[issueIds, issueMap],
|
|
);
|
|
|
|
const selectedCount = issueIds.filter((id) => selectedIds.has(id)).length;
|
|
const allSelected = issues.length > 0 && selectedCount === issues.length;
|
|
const someSelected = selectedCount > 0;
|
|
|
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
|
id: statusGroupId(status),
|
|
disabled: !dragEnabled,
|
|
});
|
|
|
|
const disableSorting = !!sortLabel;
|
|
|
|
// The infinite-scroll sentinel rides Virtuoso's Footer so it sits at the true
|
|
// end of the virtualized rows and still fires loadMore when scrolled to it.
|
|
const listComponents = useMemo(
|
|
() =>
|
|
hasMore
|
|
? { Footer: () => <InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} /> }
|
|
: EMPTY_VIRTUOSO_COMPONENTS,
|
|
[hasMore, loadMore, isLoading],
|
|
);
|
|
|
|
const computeItemKey = (_index: number, issue: Issue) => issue.id;
|
|
const itemContent = (_index: number, issue: Issue) =>
|
|
dragEnabled ? (
|
|
<DraggableListRow
|
|
issue={issue}
|
|
childProgress={childProgressMap.get(issue.id)}
|
|
project={
|
|
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
|
}
|
|
disableSorting={disableSorting}
|
|
/>
|
|
) : (
|
|
<ListRow
|
|
issue={issue}
|
|
childProgress={childProgressMap.get(issue.id)}
|
|
project={
|
|
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
|
}
|
|
/>
|
|
);
|
|
|
|
// Rows virtualize into the page's shared scroll parent. Only render when the
|
|
// section is expanded and non-empty — a Virtuoso in a collapsed (0-height /
|
|
// hidden) panel has no viewport to measure. While the shared scroll parent
|
|
// is still null (callback ref not settled after a route-return remount),
|
|
// seed a bounded slice of real rows so the first painted frame isn't blank;
|
|
// once it's set, mount the Virtuoso with a matching `initialItemCount` so the
|
|
// measurement frame keeps those rows instead of flashing empty (MUL-4750).
|
|
// The droppable, SortableContext, sticky header, and collapse are unchanged;
|
|
// virtualization only decides whether an off-screen row is in the DOM.
|
|
const rows =
|
|
isExpanded && issues.length > 0 ? (
|
|
scrollParent ? (
|
|
<Virtuoso
|
|
customScrollParent={scrollParent}
|
|
data={issues}
|
|
computeItemKey={computeItemKey}
|
|
initialItemCount={Math.min(issues.length, VIRTUOSO_SEED_COUNT)}
|
|
defaultItemHeight={LIST_ROW_ESTIMATED_HEIGHT}
|
|
increaseViewportBy={{ top: 400, bottom: 400 }}
|
|
components={listComponents}
|
|
itemContent={itemContent}
|
|
/>
|
|
) : (
|
|
<VirtuosoSeed
|
|
data={issues}
|
|
itemContent={itemContent}
|
|
computeItemKey={computeItemKey}
|
|
estimatedItemHeight={LIST_ROW_ESTIMATED_HEIGHT}
|
|
/>
|
|
)
|
|
) : null;
|
|
|
|
return (
|
|
<Accordion.Item value={status} ref={dragEnabled ? setDroppableRef : undefined}>
|
|
<Accordion.Header
|
|
className={`group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent ${
|
|
isOver && !isExpanded
|
|
? "ring-2 ring-brand/25 bg-accent/15"
|
|
: ""
|
|
}`}
|
|
>
|
|
<div className="pl-3 flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = someSelected && !allSelected;
|
|
}}
|
|
onChange={() => {
|
|
if (allSelected) {
|
|
deselect(issueIds);
|
|
} else {
|
|
select(issueIds);
|
|
}
|
|
}}
|
|
className="cursor-pointer accent-primary"
|
|
/>
|
|
</div>
|
|
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-2 h-full text-left outline-none cursor-pointer">
|
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
|
|
<StatusHeading status={status} count={total} />
|
|
</Accordion.Trigger>
|
|
{onCreateIssue && (
|
|
<div className="pr-2">
|
|
{/* Lazy-mounted tooltip machinery — see DeferredTooltip. */}
|
|
<DeferredTooltip
|
|
content={t(($) => $.list.add_issue_tooltip)}
|
|
trigger={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="rounded-full text-muted-foreground opacity-0 group-hover/header:opacity-100 transition-opacity"
|
|
onClick={() => {
|
|
const defaults = {
|
|
status,
|
|
...(projectId ? { project_id: projectId } : {}),
|
|
};
|
|
onCreateIssue(defaults);
|
|
}}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Accordion.Header>
|
|
<Accordion.Panel>
|
|
{issues.length > 0 ? (
|
|
dragEnabled ? (
|
|
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
|
{rows}
|
|
</SortableContext>
|
|
) : (
|
|
rows
|
|
)
|
|
) : (
|
|
<p className="py-6 text-center text-xs text-muted-foreground">
|
|
{t(($) => $.list.empty_status)}
|
|
</p>
|
|
)}
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Memoized: the surface controller re-renders on loading-flag flips (e.g. a
|
|
* query enabling when the view changes) — without memo every such flip
|
|
* re-rendered this entire view tree (hundreds of ms). All props are
|
|
* referentially stable useMemo/useCallback outputs from the controller.
|
|
*/
|
|
export const ListView = memo(ListViewImpl);
|