import { type TransactionStatusCode, transactionStatusLabels } from "@olenbetong/appframe-updater"; import { Box, Text } from "ink"; import { type ReactNode, useMemo } from "react"; import type { Server } from "../lib/Server.js"; import { FullScreenBox } from "./FullScreenBox.js"; import { STATUS_DIRTY_INDICATOR } from "./tableFormatting.js"; import { useCommand } from "./useCommand.js"; import { useScreenSize } from "./useScreenSize.js"; import { useScrollableText } from "./useScrollableText.js"; import { useTransactionEdits } from "./useTransactionEdits.js"; import { useTransactions } from "./useTransactions.js"; import { useTransactionText } from "./useTransactionText.js"; import { useVirtualText } from "./useVirtualText.js"; const PREVIEW_SAVE_SHORTCUTS = ["ctrl+s", "meta+s"]; const NO_TRANSACTION_FILTER = "1 = 0"; function escapePrimKey(primKey: string) { return primKey.replace(/'/g, "''"); } function createPrimKeyFilter(primKey: string | null) { if (!primKey) { return NO_TRANSACTION_FILTER; } return `PrimKey = '${escapePrimKey(primKey)}'`; } export type TransactionsPreviewDialogProps = { server: Server; namespace: string | number; frameWidth: number; focusedPrimKey: string | null; focusNext: () => void; focusPrevious: () => void; }; function Shortcut({ keyboard, command }: { keyboard: string; command: string }) { return ( [{keyboard}] {command} ); } export function TransactionsPreviewDialog({ server, namespace, frameWidth, focusedPrimKey, focusNext, focusPrevious, }: TransactionsPreviewDialogProps) { let transactionFilter = useMemo(() => createPrimKeyFilter(focusedPrimKey), [focusedPrimKey]); let { loading, error, refresh, normalizedTransactions, transactionMap } = useTransactions({ server, namespace, filter: transactionFilter, }); let selectedPrimKeys = useMemo( () => (focusedPrimKey ? new Set([focusedPrimKey]) : new Set()), [focusedPrimKey], ); let { pendingStatuses, cycleStatus, save } = useTransactionEdits({ normalizedTransactions, transactionRecordMap: transactionMap, selectedPrimKeys, server, refresh, }); let record = focusedPrimKey ? transactionMap.get(focusedPrimKey) : undefined; let pendingStatus = focusedPrimKey ? pendingStatuses[focusedPrimKey] : undefined; let statusPreviewValue = useMemo(() => { if (!record) { return ""; } let originalStatus = record.Status; let numericOriginal = typeof originalStatus === "number" ? originalStatus : typeof originalStatus === "string" ? Number(originalStatus) : null; let baseText: string; if (pendingStatus !== undefined) { baseText = String(pendingStatus); } else if (originalStatus === null) { baseText = "null"; } else if (originalStatus === undefined) { baseText = ""; } else { baseText = String(originalStatus); } let labelSource: number | null = null; if (pendingStatus !== undefined) { labelSource = pendingStatus; } else if (numericOriginal !== null && Number.isFinite(numericOriginal)) { labelSource = numericOriginal; } if (labelSource !== null) { let label = transactionStatusLabels[labelSource as TransactionStatusCode]; if (label) { baseText = baseText.length > 0 ? `${baseText} (${label})` : `(${label})`; } } let isDirty = false; if (pendingStatus !== undefined) { isDirty = numericOriginal === null || !Number.isFinite(numericOriginal) || pendingStatus !== numericOriginal; } if (isDirty) { baseText = `${baseText}${STATUS_DIRTY_INDICATOR}`; } return baseText; }, [pendingStatus, record]); let { text: transactionText, dialogWidth } = useTransactionText({ transaction: record, statusPreviewValue, frameWidth, }); let { height: screenHeight } = useScreenSize(); let headerLineCount = 3 + (record?.Name ? 1 : 0); let marginLines = 1; // Box marginTop between header and content let baseNonContentLines = headerLineCount + marginLines; let contentLineCount = transactionText.length > 0 ? transactionText.length : 1; let computeLayout = (extraNonContentLines: number) => { let nonContentLines = baseNonContentLines + extraNonContentLines; let naturalDialogHeight = nonContentLines + contentLineCount; let maxDialogHeight = Math.max(screenHeight - 4, nonContentLines + 1); maxDialogHeight = Math.min(maxDialogHeight, Math.max(screenHeight - 2, 1)); let dialogHeightValue = Math.min(naturalDialogHeight, maxDialogHeight); let contentHeightValue = Math.max(dialogHeightValue - nonContentLines, 1); return { dialogHeight: dialogHeightValue, contentHeight: contentHeightValue, nonContentLines }; }; let { dialogHeight, contentHeight } = computeLayout(0); let showScrollSummary = record && transactionText.length > contentHeight; if (showScrollSummary) { ({ dialogHeight, contentHeight } = computeLayout(2)); } let previewSignature = useMemo( () => `${focusedPrimKey ?? ""}|${transactionText.join("|")}`, [focusedPrimKey, transactionText], ); let { scrollOffset } = useScrollableText({ textLength: transactionText.length, viewportHeight: contentHeight, signature: previewSignature, }); let { visibleText, totalLines, offset } = useVirtualText({ text: transactionText, viewportHeight: contentHeight, scrollOffset, }); useCommand("left", focusPrevious, true); useCommand("right", focusNext, true); useCommand( PREVIEW_SAVE_SHORTCUTS, () => { if (focusedPrimKey) { save({ primKey: focusedPrimKey }); } }, focusedPrimKey !== null, ); useCommand( "s", () => { if (focusedPrimKey) { cycleStatus(focusedPrimKey, { exclusive: true }); } }, focusedPrimKey !== null, ); let summaryText = useMemo(() => { if (totalLines === 0) { return ""; } let start = offset + 1; let end = Math.min(offset + contentHeight, totalLines); return `Showing ${start}–${end} of ${totalLines}`; }, [contentHeight, offset, totalLines]); let bodyContent: ReactNode; if (!focusedPrimKey) { bodyContent = No transaction selected.; } else if (error) { bodyContent = {error.message}; } else if (!record) { bodyContent = loading ? ( Loading transaction… ) : ( Transaction not found. ); } else if (transactionText.length === 0) { bodyContent = No data available.; } else { bodyContent = visibleText.map((line, index) => ( {line.length > 0 ? line : " "} )); } return ( Transaction preview {record?.Name ? {record.Name} : null} · ·{" "} · ·{" "} · {bodyContent} {showScrollSummary ? ( {summaryText} ) : null} ); }