/* Copyright 2026 Marimo. All rights reserved. */ import type { Column, OnChangeFn, RowSelectionState, } from "@tanstack/react-table"; import { AlertTriangle, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Info, } from "lucide-react"; import { type KeyboardEvent, useId, useRef, useState } from "react"; import { useLocale } from "react-aria"; import { ColumnName } from "@/components/datasources/components"; import { CopyClipboardIcon } from "@/components/icons/copy-icon"; import { Spinner } from "@/components/icons/spinner"; import { KeyboardHotkeys } from "@/components/shortcuts/renderShortcut"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandInput } from "@/components/ui/command"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { DelayMount } from "@/components/utils/delay-mount"; import { useAsyncData } from "@/hooks/useAsyncData"; import { Banner, ErrorBanner } from "@/plugins/impl/common/error-banner"; import type { GetRowResult } from "@/plugins/impl/DataTablePlugin"; import { NAMELESS_COLUMN_PREFIX, renderCellValue } from "../columns"; import { prettifyRowCount } from "../pagination"; import { type FieldTypesWithExternalType, INDEX_COLUMN_NAME, SELECT_COLUMN_ID, TOO_MANY_ROWS, type TooManyRows, } from "../types"; export interface RowViewerPanelProps { rowIdx: number; setRowIdx: (rowIdx: number) => void; totalRows: number | TooManyRows; fieldTypes: FieldTypesWithExternalType | undefined | null; getRow: (rowIdx: number) => Promise; isSelectable: boolean; isRowSelected: boolean; handleRowSelectionChange?: OnChangeFn; } export const RowViewerPanel: React.FC = ({ rowIdx, setRowIdx, totalRows, fieldTypes, getRow, isSelectable, isRowSelected, handleRowSelectionChange, }: RowViewerPanelProps) => { const [searchQuery, setSearchQuery] = useState(""); const panelRef = useRef(null); const checkboxId = useId(); const { locale } = useLocale(); const tooManyRows = totalRows === TOO_MANY_ROWS; const { data: rows, error } = useAsyncData(async () => { const data = await getRow(rowIdx); return data.rows; }, [getRow, rowIdx, totalRows]); const setRow = (rowIdx: number) => { if (rowIdx < 0 || (typeof totalRows === "number" && rowIdx >= totalRows)) { return; } setRowIdx(rowIdx); }; const toggleRowSelection = () => { handleRowSelectionChange?.((prev) => { if (isRowSelected) { // Remove this row from selection const { [rowIdx]: removedRow, ...rest } = prev; return rest; } // Add this row to selection return { ...prev, [rowIdx]: true }; }); }; // Total rows may change after the row viewer panel is opened if (!tooManyRows && rowIdx > totalRows) { setRow(totalRows - 1); } const handleKeyDown = (e: KeyboardEvent) => { // Don't intercept keys when typing in an input if (e.target instanceof HTMLInputElement) { return; } switch (e.key) { case "ArrowLeft": setRow(rowIdx - 1); break; case "ArrowRight": setRow(rowIdx + 1); break; case " ": e.preventDefault(); toggleRowSelection(); break; } }; const buttonStyles = "h-6 w-6 p-0.5"; const renderTable = () => { if (error) { return ; } if (totalRows === 0) { return ( ); } if (!rows) { return ( ); } if (rows.length !== 1) { const message = tooManyRows ? "LazyFrame, no data available." : `Expected 1 row, got ${rows.length} rows. Please report the issue.`; return ( ); } const currentRow = rows[0]; if (typeof currentRow !== "object" || currentRow === null) { return ( ); } const rowValues: Record = {}; for (const [columnName, columnValue] of Object.entries(currentRow)) { if (columnName === SELECT_COLUMN_ID || columnName === INDEX_COLUMN_NAME) { continue; } if (columnName.startsWith(NAMELESS_COLUMN_PREFIX)) { // Remove the prefix rowValues[columnName.slice(NAMELESS_COLUMN_PREFIX.length)] = columnValue; } else { rowValues[columnName] = columnValue; } } return ( Column Value {fieldTypes?.map(([columnName, [dataType, _externalType]]) => { const columnValue = rowValues[columnName]; if (!inSearchQuery({ columnName, columnValue, searchQuery })) { return null; } const mockColumn = { id: columnName, columnDef: { meta: { dataType, }, }, getColumnFormatting: () => undefined, applyColumnFormatting: (value) => value, } as Column; const cellContent = renderCellValue({ column: mockColumn, renderValue: () => columnValue, getValue: () => columnValue, selectCell: undefined, cellStyles: "text-left break-word", }); const copyValue = typeof columnValue === "object" ? JSON.stringify(columnValue) : String(columnValue); return ( {columnName}} dataType={dataType} />
{cellContent}
); })}
); }; return (
{isSelectable && (
)} {tooManyRows ? `Row ${rowIdx + 1}` : `Row ${rowIdx + 1} of ${prettifyRowCount(totalRows, locale)}`}
{renderTable()}
); }; export function inSearchQuery({ columnName, columnValue, searchQuery, }: { columnName: string; columnValue: unknown; searchQuery: string; }) { const colName = columnName.toLowerCase(); const searchQueryLower = searchQuery.toLowerCase(); let columnValueString = typeof columnValue === "object" ? JSON.stringify(columnValue) : String(columnValue); columnValueString = columnValueString.toLowerCase(); return ( colName.includes(searchQueryLower) || columnValueString.includes(searchQueryLower) ); } const SimpleBanner: React.FC<{ kind: "info" | "warn" | "danger"; Icon: React.FC>; message: string; }> = ({ kind, Icon, message }) => { return ( {message} ); };