import type { TransactionFilter } from "@olenbetong/appframe-updater"; import { Box, Text, useApp } from "ink"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { Server } from "../lib/Server.js"; import { TransactionsPreviewDialog } from "./TransactionsPreviewDialog.js"; import { createStatusLine } from "./tableFormatting.js"; import { useCommand } from "./useCommand.js"; import { useScreenSize } from "./useScreenSize.js"; import { useTransactionEdits } from "./useTransactionEdits.js"; import { type TransactionsEditorRefreshOptions, useTransactions } from "./useTransactions.js"; import { useTransactionsSelection } from "./useTransactionsSelection.js"; import { useTransactionsTableLayout } from "./useTransactionsTableLayout.js"; import { CURSOR_PREFIX_WIDTH, HEADER_PREFIX, useVirtualScrolling } from "./useVirtualScrolling.js"; import { withFullScreen } from "./withFullScreen.js"; const HEADER_LINES = 2; const BASE_FOOTER_LINES = 1; const STATUS_LINES = 1; const FRAME_BORDER_LINES = 2; const SAVE_SHORTCUTS = ["ctrl+s", "meta+s"]; const EXIT_SHORTCUTS = ["ctrl+x", "meta+x"]; export interface TransactionsEditorProps { server: Server; namespace: string | number; showErrorColumn?: boolean; filter?: TransactionFilter; } export function TransactionsEditor({ filter, server, namespace, showErrorColumn = true }: TransactionsEditorProps) { let { width: terminalColumns, height: terminalRows } = useScreenSize(); let { exit } = useApp(); let [cursor, setCursor] = useState(0); let [actionMessage, setActionMessage] = useState(null); let [actionColor, setActionColor] = useState("gray"); let [previewOpen, setPreviewOpen] = useState(false); let onActionMessage = useCallback((message: string | null, color: "gray" | "red" | "yellow" | "green") => { setActionMessage(message); setActionColor(color); }, []); let { transactions, loading, error, refresh: fetchTransactions, normalizedTransactions, transactionMap, } = useTransactions({ server, namespace, filter }); let [lastRefreshOptions, setLastRefreshOptions] = useState({ preserveViewport: false, anchorPrimKey: null, }); let refresh = useCallback( async (options: TransactionsEditorRefreshOptions = {}) => { let { preserveViewport = false, anchorPrimKey = null } = options; setLastRefreshOptions({ preserveViewport, anchorPrimKey }); await fetchTransactions(options); }, [fetchTransactions], ); let { selectedPrimKeys, toggleSelection } = useTransactionsSelection(transactions); let { pendingStatuses, modifiedPrimKeys, cycleStatus, save } = useTransactionEdits({ normalizedTransactions, transactionRecordMap: transactionMap, selectedPrimKeys, server, onActionMessage, refresh, }); let displayTransactions = useMemo( () => normalizedTransactions.map((transaction) => ({ ...transaction, Status: pendingStatuses[transaction.PrimKey] ?? transaction.Status, })), [normalizedTransactions, pendingStatuses], ); let instructionsLineCount = BASE_FOOTER_LINES; let frameHeight = Math.max( terminalRows - instructionsLineCount, FRAME_BORDER_LINES + HEADER_LINES + STATUS_LINES + 1, ); let innerHeight = Math.max(frameHeight - FRAME_BORDER_LINES, 0); let dataAreaHeight = Math.max(innerHeight - HEADER_LINES - STATUS_LINES, 0); let frameWidth = Math.max(terminalColumns, FRAME_BORDER_LINES + CURSOR_PREFIX_WIDTH + 1); let innerWidth = Math.max(frameWidth - FRAME_BORDER_LINES, 0); let tableWidth = Math.max(innerWidth - CURSOR_PREFIX_WIDTH, 0); let { columns, rows, widths, tableContentWidth, lineWidth, header, divider } = useTransactionsTableLayout({ displayTransactions, showErrorColumn, tableWidth, }); let { bodyLines, pageStep, stats, setOffset } = useVirtualScrolling({ cursor, setCursor, dataAreaHeight, rows, columns, widths, tableContentWidth, lineWidth, normalizedTransactions, selectedPrimKeys, modifiedPrimKeys, loading, }); useEffect(() => { let { preserveViewport = false, anchorPrimKey = null } = lastRefreshOptions; if (!preserveViewport) { setOffset(0); setCursor(0); return; } if (anchorPrimKey) { let index = transactions.findIndex((transaction) => transaction.PrimKey === anchorPrimKey); if (index >= 0) { setCursor(index); } } }, [lastRefreshOptions, setOffset, transactions]); function handlePreview() { let focused = normalizedTransactions[cursor]; if (!focused) { onActionMessage("No transaction focused for preview.", "yellow"); return; } setPreviewOpen(true); } let totalRowCount = stats.totalRowCount; function focusPrevious() { setCursor((current) => (totalRowCount === 0 ? 0 : Math.max(current - 1, 0))); } function focusNext() { setCursor((current) => { if (totalRowCount === 0) { return 0; } return Math.min(current + 1, totalRowCount - 1); }); } function pageUpCursor() { setCursor((current) => Math.max(current - pageStep, 0)); } function pageDownCursor() { setCursor((current) => { if (totalRowCount === 0) { return 0; } return Math.min(current + pageStep, totalRowCount - 1); }); } let focusedPrimKey = normalizedTransactions[cursor]?.PrimKey ?? null; function handlePreviewClose() { if (previewOpen) { setPreviewOpen(false); refresh({ preserveViewport: true, anchorPrimKey: normalizedTransactions[cursor]?.PrimKey ?? null }); } } let tableCommandsEnabled = !previewOpen; useCommand("up", focusPrevious, tableCommandsEnabled); useCommand("down", focusNext, tableCommandsEnabled); useCommand("pageup", pageUpCursor, tableCommandsEnabled); useCommand("pagedown", pageDownCursor, tableCommandsEnabled); useCommand("home", () => setCursor(0), tableCommandsEnabled); useCommand("end", () => setCursor(Math.max(totalRowCount - 1, 0)), tableCommandsEnabled); useCommand(SAVE_SHORTCUTS, save, tableCommandsEnabled); useCommand("space", () => toggleSelection(focusedPrimKey), tableCommandsEnabled); useCommand("s", () => cycleStatus(focusedPrimKey), tableCommandsEnabled); useCommand("r", () => refresh({ preserveViewport: true, anchorPrimKey: focusedPrimKey }), tableCommandsEnabled); useCommand("p", handlePreview, tableCommandsEnabled); useCommand("escape", handlePreviewClose, true); useCommand(EXIT_SHORTCUTS, exit, true); let statusParts: string[] = [`showing ${stats.firstVisibleRow}-${stats.lastVisibleRow} of ${stats.totalRowCount}`]; if (selectedPrimKeys.size > 0) { statusParts.push(`${selectedPrimKeys.size} selected`); } if (modifiedPrimKeys.size > 0) { statusParts.push(`${modifiedPrimKeys.size} modified`); } let statsText = statusParts.length > 0 ? `(${statusParts.join(" - ")})` : ""; let statusMessage = ""; let statusColor = "gray"; if (error) { statusMessage = error.message.replace(/\s+/g, " ").trim(); statusColor = "red"; } else if (loading) { statusMessage = "Loading transactions…"; statusColor = "blue"; } else if (actionMessage) { statusMessage = actionMessage.replace(/\s+/g, " ").trim(); statusColor = actionColor; } let statusLine = createStatusLine(statusMessage, statsText, lineWidth, HEADER_PREFIX); return ( {header} {divider} {bodyLines.map((line) => ( {line.text} ))} {statusLine} ↑/↓ move · PgUp/PgDn jump · Space toggle select · s cycle status · ctrl+s save · ctrl+x exit · r refresh · p preview · esc close preview {previewOpen ? ( ) : null} ); } export async function renderTransactionsEditor({ filter = "edit", ...props }: TransactionsEditorProps) { const ink = withFullScreen(); await ink.start(); return ink; }