import { useCallback, useEffect, useMemo, useRef } from "react"; import { Box, Text, TextAttributes, type InputRenderable } from "../ui"; import { InputSearchBar } from "../components"; import { ListView, type ListViewItem } from "../components/ui/list-view"; import { useShortcut, useViewport } from "../react/input"; import { useThemeColors } from "../theme/theme-context"; import { truncateToDisplayWidth } from "../utils/format"; import { isPlainKey } from "../utils/keyboard"; import { t, tf } from "../i18n"; import type { GallerySearchState, LayoutGalleryController } from "./gallery"; import { describeArrangement, formatPublishedAt, summarizeLayoutPanes, type GalleryEntry, } from "./model"; import { linkedLayoutMarker } from "./linked"; const DETAILS_WIDTH = 42; /** Rows PageUp and PageDown skip. */ const PAGE_STEP = 10; interface GalleryRow extends ListViewItem { entry: GalleryEntry | null; action?: () => void; } function discoverStatusRow(controller: LayoutGalleryController): GalleryRow | null { if (!controller.signedIn) { return { id: "discover:login", label: "Log in to browse community layouts", detail: "Open login", entry: null, action: controller.requestSignIn, }; } switch (controller.discover.state.status) { case "idle": case "loading": return { id: "discover:loading", label: "Loading community layouts…", disabled: true, entry: null }; case "error": return { id: "discover:retry", label: "Retry Discover", detail: controller.discover.state.error, entry: null, action: controller.discover.refresh, }; default: return controller.community.length === 0 ? { id: "discover:empty", label: "No community layouts yet", disabled: true, entry: null } : null; } } export function LayoutGalleryTerminal({ controller, search, dialogOpen, focused, width, height, }: { controller: LayoutGalleryController; /** Search focus lives with the gallery, whose `/` key focuses it. */ search: GallerySearchState; dialogOpen: boolean; focused: boolean; width?: number; height?: number; }) { const colors = useThemeColors(); const viewport = useViewport(); const paneWidth = width ?? viewport.width; const paneHeight = height ?? viewport.height; const detailsWidth = Math.min(DETAILS_WIDTH, Math.max(28, Math.floor(paneWidth * 0.42))); const inputRef = useRef(null); const searchFocused = search.active; const { activate: activateLayout, community, install: installLayout, owned, select, selectedId, teamSections, teamLayouts, } = controller; const discoverStatus = discoverStatusRow(controller); const rows = useMemo(() => { const built: GalleryRow[] = [ { id: "heading:owned", label: tf("YOUR LAYOUTS ({count})", { count: String(owned.length) }), disabled: true, entry: null, }, ...owned.map((entry): GalleryRow => ({ id: entry.id, label: `${entry.team ? `${entry.team.shortName}· ` : ""}${entry.name}${linkedLayoutMarker(entry.linked ?? null)}${entry.active ? " ●" : ""}`, right: entry.linked ? `r${entry.revision ?? "?"}${entry.linked.updateAvailable ? ` → r${entry.linked.updateAvailable}` : ""}` : describeArrangement(entry.layout), current: entry.active, entry, })), ...teamSections.flatMap(({ team, entries }): GalleryRow[] => [ { id: `heading:team:${team.id}`, label: tf("{team} ({count})", { team: `${team.shortName}· ${team.name.toUpperCase()}`, count: String(entries.length) }), disabled: true, entry: null, }, ...(teamLayouts.state.status === "error" && entries.length === 0 ? [{ id: `team:${team.id}:retry`, label: "Retry", detail: teamLayouts.state.error, entry: null, action: teamLayouts.refresh }] : teamLayouts.state.status === "loading" && entries.length === 0 ? [{ id: `team:${team.id}:loading`, label: "Loading team layouts…", disabled: true, entry: null }] : entries.length === 0 ? [{ id: `team:${team.id}:empty`, label: "No team layouts yet.", disabled: true, entry: null }] : entries.map((entry): GalleryRow => ({ id: entry.id, label: `${entry.name}${entry.index !== null ? " (open)" : ""}`, detail: entry.author ?? "", right: `r${entry.revision ?? 1} · ${formatPublishedAt(entry.publishedAt ?? "")}`, entry, }))), ]), { id: "heading:discover", label: tf("DISCOVER ({count})", { count: String(community.length) }), disabled: true, entry: null, }, ]; if (discoverStatus) built.push(discoverStatus); for (const entry of community) { built.push({ id: entry.id, label: entry.name, detail: entry.author ?? "", right: describeArrangement(entry.layout), entry, }); } return built; }, [community, discoverStatus, owned, teamLayouts.refresh, teamLayouts.state, teamSections]); const selectableRows = useMemo(() => rows.filter((row) => row.entry || row.action), [rows]); // The cursor is the gallery's selection, so it follows a layout moved with // Shift+J/K and a new search puts it on the best match. With none it rests on // the layout in use. const searching = controller.query.trim().length > 0; const selectedRow = selectableRows.find((row) => row.id === selectedId) ?? (searching ? undefined : selectableRows.find((row) => row.entry?.kind === "owned" && row.entry.active)) ?? selectableRows[0] ?? null; const selectedIndex = selectedRow ? rows.indexOf(selectedRow) : -1; const selectedEntry = selectedRow?.entry ?? null; const cursorPosition = selectedRow ? selectableRows.indexOf(selectedRow) : -1; // The gallery's keys act on its selection, so it names the row under the cursor. useEffect(() => { const id = selectedRow?.id ?? null; if (id !== selectedId) select(id); }, [select, selectedId, selectedRow?.id]); const moveTo = useCallback((position: number) => { const next = selectableRows[Math.max(0, Math.min(selectableRows.length - 1, position))]; if (next) select(next.id); }, [select, selectableRows]); const focusSearch = search.focus; const blurSearch = search.blur; // The details panel is always on screen here, so Enter runs the action it shows // instead of stepping through a separate detail state. Entry keys (Enter on a // layout, o, a, e, c, d, t, g, x, Shift+J/K, n, p, /) and Esc are the // gallery's; this list adds moving the cursor and the status rows (log in, // retry). const activate = useCallback((row: GalleryRow | null) => { if (!row) return; if (row.action) { row.action(); return; } if (!row.entry) return; if (row.entry.kind === "community" || row.entry.kind === "team") installLayout(row.entry); else activateLayout(row.entry); }, [activateLayout, installLayout]); // Unscoped on purpose: a scoped handler would outrank the pane menu opened // over the gallery, and j/k/Enter belong to that menu while it is open. useShortcut((event) => { if (dialogOpen || event.targetEditable || searchFocused) return; const run = (action: () => void) => { event.preventDefault(); event.stopPropagation(); action(); }; if (isPlainKey(event, "down", "j")) run(() => moveTo(cursorPosition + 1)); else if (isPlainKey(event, "up", "k")) { run(() => { if (event.name === "up" && cursorPosition <= 0) focusSearch(); else moveTo(cursorPosition - 1); }); } else if (isPlainKey(event, "home")) run(() => moveTo(0)); else if (isPlainKey(event, "end")) run(() => moveTo(selectableRows.length - 1)); else if (isPlainKey(event, "pageup")) run(() => moveTo(cursorPosition - PAGE_STEP)); else if (isPlainKey(event, "pagedown")) run(() => moveTo(cursorPosition + PAGE_STEP)); else if (isPlainKey(event, "enter", "return") && selectedRow?.action) run(() => activate(selectedRow)); }, { allowEditable: true, enabled: focused && !dialogOpen, phase: "before" }); const bodyHeight = Math.max(4, paneHeight - 1); return ( { const row = rows[index]; if (row?.entry || row?.action) select(row.id); }} onActivate={(_item, index) => activate(rows[index] ?? null)} renderRow={(item, state) => { const row = item as GalleryRow; if (!row.entry && !row.action) { return ( {item.label} ); } return ( {`${state.selected ? "\u25b8 " : " "}${item.label}`} {item.right ?? item.detail ?? ""} ); }} remoteRole="layout-gallery-list" remoteLabel={t("Layouts")} /> {selectedEntry ? ( ) : selectedRow?.action ? ( {selectedRow.label} {selectedRow.detail && {selectedRow.detail}} ) : ( {t("No layout selected.")} )} ); } function LayoutDetails({ controller, entry, width, height, }: { controller: LayoutGalleryController; entry: GalleryEntry; width: number; height: number; }) { const colors = useThemeColors(); const allPanes = summarizeLayoutPanes(entry.layout, controller.panes); const missing = allPanes.filter((pane) => pane.missing); // Leave room for the title block and the optional warning. const paneBudget = Math.max(3, height - 8); const overflow = Math.max(0, allPanes.length - paneBudget); const panes = overflow > 0 ? allPanes.slice(0, paneBudget - 1) : allPanes; return ( {entry.name} {entry.team && ( {`${entry.team.shortName}· ${entry.team.name}${entry.revision ? ` · r${entry.revision}` : ""}`} )} {entry.author && ( {entry.publishedAt ? `${entry.author} · ${formatPublishedAt(entry.publishedAt)}` : entry.author} )} {entry.linked && ( {entry.linked.updateAvailable ? tf("Team has r{revision}. g to pull.", { revision: String(entry.linked.updateAvailable) }) : entry.linked.dirty ? t("Edited since the last publish. t to publish.") : t("In sync with the team.")} )} {describeArrangement(entry.layout)} {panes.map((pane) => { const trailing = pane.missing ? t("unavailable") : pane.paneId; const label = pane.symbol ? `${pane.name} · ${pane.symbol}` : pane.name; return ( {truncateToDisplayWidth(label, Math.max(4, width - 5 - trailing.length))} {trailing} ); })} {overflow > 0 && ( {tf("+{count} more panes", { count: String(allPanes.length - panes.length) })} )} {missing.length > 0 && ( <> {missing.length === 1 ? t("1 pane type is not installed here.") : tf("{count} pane types are not installed here.", { count: String(missing.length) })} )} ); }