import { memo, useMemo, type RefObject } from "react"; import { t } from "../../../i18n"; import { useRemoteUiNode } from "../../../remote/semantic-tree"; import { commandBarBadgeText, type CommandBarBadgeTone } from "../../../theme/colors"; import { useThemeColors } from "../../../theme/theme-context"; import type { CommandBarResultLineSegment } from "../../../types/plugin"; import { Box, ScrollBox, Text, TextAttributes, type ScrollBoxRenderable, } from "../../../ui"; import { truncateTextSegments } from "../../../utils/format"; import { Spinner } from "../../ui"; import { useCommandBarPalette } from "../panel/palette"; import { getRowPresentation, truncateText } from "../view-model"; import { BADGE_COLUMN_WIDTH, BADGE_GAP, badgeConsumesRight, resolveRowBadge, } from "./badge"; import { getListRowsHeight, getResultItemLines, type CommandBarListRow, type ListScreenState, type ResultItem, } from "./model"; export type CommandBarListScrollEvent = { stopPropagation: () => void; preventDefault: () => void; scroll?: { direction?: string; delta?: number }; }; /** Columns every row gives up to the badge column, badge or not. */ const BADGE_INDENT = BADGE_COLUMN_WIDTH + BADGE_GAP; interface CommandBarListItemRowProps { item: ResultItem; globalIdx: number; isSelected: boolean; isHovered: boolean; listKind: ListScreenState["kind"]; listTitle: string; listQuery: string; contentPadding: number; labelWidth: number; trailingWidth: number; nativePaneChrome: boolean; onHoverIndex: (index: number | null) => void; onListScroll: (event: CommandBarListScrollEvent) => void; onRowMouseDown: (event: any, item: ResultItem, globalIdx: number) => void; } const CommandBarListItemRow = memo(function CommandBarListItemRow({ item, globalIdx, isSelected, isHovered, listKind, listTitle, listQuery, contentPadding, labelWidth, trailingWidth, nativePaneChrome, onHoverIndex, onListScroll, onRowMouseDown, }: CommandBarListItemRowProps) { const palette = useCommandBarPalette(nativePaneChrome); const presentation = getRowPresentation(item, isSelected, trailingWidth > 0); const badge = resolveRowBadge(item); // The badge column and its gap come out of the label, so the right column // stays where it is for rows with and without a badge alike. const labelColumnWidth = Math.max(1, labelWidth - BADGE_INDENT); // A long title stops one cell short of the right column, so its ellipsis // never runs into the date or shortcut sitting there. const label = truncateText(presentation.label, Math.max(1, labelColumnWidth - (trailingWidth > 0 ? 1 : 0))); // "current" outranks the shortcut on the right; otherwise a badge lifted from // `right` must not be repeated there. const trailing = badgeConsumesRight(item) && !item.current ? "" : truncateText(presentation.trailing, trailingWidth); const lineWidth = labelColumnWidth + trailingWidth; const lines = useMemo( () => getResultItemLines(item).map((line) => truncateTextSegments( line.segments, lineWidth, (ellipsis) => ({ text: ellipsis, emphasis: "muted" as const }), )), [item, lineWidth], ); const activate = () => onRowMouseDown({ preventDefault() {}, stopPropagation() {}, }, item, globalIdx); useRemoteUiNode({ role: "command-bar-result", label: item.label, disabled: item.disabled === true, actions: { activate, press: activate, secondary: item.secondaryAction ? () => item.secondaryAction?.() : undefined, }, metadata: { index: globalIdx, selected: isSelected, hovered: isHovered, listKind, listTitle, listQuery, item: { id: item.id, label: item.label, detail: item.detail, category: item.category, kind: item.kind, right: item.right, shortcutQuery: item.shortcutQuery, searchText: item.searchText, checked: item.checked, current: item.current, disabled: item.disabled === true, }, }, }); return ( onHoverIndex(globalIdx)} onMouseOut={() => onHoverIndex(null)} {...(!nativePaneChrome ? { onMouseScroll: onListScroll } : {})} onMouseDown={(event: any) => onRowMouseDown(event, item, globalIdx)} data-command-bar-row-selected={nativePaneChrome && isSelected ? "true" : undefined} style={nativePaneChrome ? { borderRadius: 6 } : undefined} > {badge && } {label} {trailing} {lines.map((segments, index) => ( {segments.map((segment, segmentIndex) => ( {segment.text} ))} ))} ); }); /** * A tag set flush against the label edge in a column padded to the widest tag * in the list, so the labels line up and the tags share one right edge. Text * only: a filled box would outweigh the label it introduces. */ function CommandBarRowBadge({ text, tone, width }: { text: string; tone: CommandBarBadgeTone; width: number }) { const themeColors = useThemeColors(); return ( {truncateText(text, width).padStart(width, " ")} ); } /** * Matched runs keep their highlight on the selected row: the selection colour is * what tells you where you are, the match colour is what you were looking for. * A muted run (a source lead-in, an ellipsis) steps down one more level than * the snippet so it reads as metadata, except on the selected row where the * whole line takes the selection text so nothing there goes dim. */ function resolveLineSegmentColor( segment: CommandBarResultLineSegment, palette: ReturnType, isSelected: boolean, ): string { if (segment.emphasis === "match") return palette.match; if (isSelected) return palette.selectedText; return segment.emphasis === "muted" ? palette.heading : palette.subtle; } interface CommandBarListBodyProps { visibleListState: ListScreenState; nativeListRows: CommandBarListRow[]; listBodyHeight: number; contentPadding: number; labelWidth: number; nativePaneChrome: boolean; nativeListScrollRef: RefObject; queryDisplayWidth: number; trailingWidth: number; onHoverIndex: (index: number | null) => void; onListScroll: (event: CommandBarListScrollEvent) => void; onRowMouseDown: (event: any, item: ResultItem, globalIdx: number) => void; } export const CommandBarListBody = memo(function CommandBarListBody({ visibleListState, nativeListRows, listBodyHeight, contentPadding, labelWidth, nativePaneChrome, nativeListScrollRef, queryDisplayWidth, trailingWidth, onHoverIndex, onListScroll, onRowMouseDown, }: CommandBarListBodyProps) { const palette = useCommandBarPalette(nativePaneChrome); // Headings, messages and the spinner sit on the label edge: the badge column // is a gutter for the rows, not an indent for everything else. const labelEdgePadding = contentPadding + BADGE_INDENT; const labelEdgeWidth = Math.max(1, queryDisplayWidth - BADGE_INDENT); const visibleRows = useMemo(() => { const rows = nativeListRows; if (nativePaneChrome) return rows; const paddedRows = [...rows]; // Padded in lines, not rows: a multi-line result already fills several. let filledLines = getListRowsHeight(rows); while (filledLines < listBodyHeight) { paddedRows.push({ kind: "filler", id: `filler:${paddedRows.length}` }); filledLines += 1; } return paddedRows; }, [ listBodyHeight, nativeListRows, nativePaneChrome, ]); const renderedRows = ( <> {visibleRows.map((row) => { if (row.kind === "filler" || row.kind === "spacer") { return ; } if (row.kind === "spinner") { return ( ); } if (row.kind === "message") { return ( {truncateText(t(row.label), labelEdgeWidth)} ); } if (row.kind === "heading") { return ( {truncateText(t(row.label), labelEdgeWidth)} ); } const isSelected = row.globalIdx === visibleListState.selectedIdx; const isHovered = row.globalIdx === visibleListState.hoveredIdx && !isSelected; const itemRowKey = `item:${row.globalIdx}:${row.item.id}:${row.item.category}:${row.item.label}:${row.item.right || ""}`; return ( ); })} ); return ( {renderedRows} ); });