UNPKG

@oclif/table

Version:

Display table in terminal

458 lines (457 loc) 20.3 kB
/* eslint-disable react/prop-types */ import cliTruncate from 'cli-truncate'; import { Box, render, Text } from 'ink'; import { EventEmitter } from 'node:events'; import { env } from 'node:process'; import { sha1 } from 'object-hash'; import React from 'react'; import stringWidth from 'string-width'; import stripAnsi from 'strip-ansi'; import wrapAnsi from 'wrap-ansi'; import { BORDER_SKELETONS } from './skeletons.js'; import { allKeysInCollection, determineConfiguredWidth, determineWidthOfWrappedText, getColumns, getColumnWidth, getHeadings, intersperse, maybeStripAnsi, shouldUsePlainTable, sortData, } from './utils.js'; /** * Determine the width to use for the table. * * This allows us to use the minimum width required to display the table if the configured width is too small. */ function determineWidthToUse(columns, maxWidth, width) { const tableWidth = columns.map((c) => c.width).reduce((a, b) => a + b, 0) + columns.length + 1; return width ?? (tableWidth < maxWidth ? maxWidth : tableWidth); } function determineTruncatePosition(overflow) { switch (overflow) { case 'truncate-end': { return 'end'; } case 'truncate-middle': { return 'middle'; } case 'truncate-start': { return 'start'; } default: { return 'end'; } } } export function formatTextWithMargins({ horizontalAlignment, overflow, padding, trimWhitespace = true, value, width, }) { function calculateMargins(spaces) { let marginLeft; let marginRight; if (spaces <= 0 || Number.isNaN(spaces)) { return { marginLeft: 0, marginRight: 0 }; } if (horizontalAlignment === 'left') { marginLeft = padding; marginRight = spaces - marginLeft; } else if (horizontalAlignment === 'center') { marginLeft = Math.floor(spaces / 2); marginRight = Math.ceil(spaces / 2); } else { marginRight = padding; marginLeft = spaces - marginRight; } return { // Ensure that the margin is never negative marginLeft: Math.max(0, marginLeft), marginRight: Math.max(0, marginRight), }; } // Some terminals don't play nicely with zero-width characters, so we replace them with spaces. // https://github.com/sindresorhus/terminal-link/issues/18 // https://github.com/Shopify/cli/pull/995 const valueWithNoZeroWidthChars = String(value).replaceAll('​', ' '); const spaceForText = width - padding * 2; // Handle the simple case where text fits within the available space and doesn't contain any newlines. if (stringWidth(stripAnsi(valueWithNoZeroWidthChars)) <= spaceForText && !valueWithNoZeroWidthChars.includes('\n')) { const spaces = width - stringWidth(stripAnsi(valueWithNoZeroWidthChars)); return { text: valueWithNoZeroWidthChars, ...calculateMargins(spaces), }; } // Handle the case where the text needs to be wrapped. if (overflow === 'wrap') { const wrappedText = wrapAnsi(valueWithNoZeroWidthChars, spaceForText, { hard: true, trim: trimWhitespace, wordWrap: true, }).replace(/^\n/, ''); // remove leading newline (wrapAnsi adds it to emojis) const { marginLeft, marginRight } = calculateMargins(width - determineWidthOfWrappedText(stripAnsi(wrappedText))); const lines = wrappedText.split('\n').map((line, idx) => { const { marginLeft: lineSpecificLeftMargin, marginRight: lineSpecificRightMargin } = calculateMargins(width - stringWidth(stripAnsi(line))); if (horizontalAlignment === 'left') { return idx === 0 ? `${line}${' '.repeat(lineSpecificRightMargin - marginRight)}` : `${' '.repeat(marginLeft)}${line}${' '.repeat(lineSpecificRightMargin - marginRight)}`; } if (horizontalAlignment === 'center') { return idx === 0 ? `${' '.repeat(lineSpecificLeftMargin - marginLeft)}${line}${' '.repeat(lineSpecificRightMargin)}` : `${' '.repeat(lineSpecificLeftMargin)}${line}${' '.repeat(lineSpecificRightMargin - marginRight)}`; } // right alignment return idx === 0 ? `${' '.repeat(Math.max(0, lineSpecificLeftMargin - marginLeft))}${line}${' '.repeat(lineSpecificRightMargin - marginRight)}` : `${' '.repeat(lineSpecificLeftMargin)}${line}${' '.repeat(lineSpecificRightMargin - marginRight)}`; }); return { marginLeft, marginRight, text: lines.join('\n'), }; } // Handle the case where the text needs to be truncated. const text = cliTruncate(valueWithNoZeroWidthChars.replaceAll('\n', ' '), spaceForText, { position: determineTruncatePosition(overflow), }); const spaces = width - stripAnsi(text).length; return { text, ...calculateMargins(spaces), }; } function setupTable(props) { const { data, filter, horizontalAlignment = 'left', maxWidth, noStyle = false, overflow = 'truncate', padding = 1, sort, title, trimWhitespace, verticalAlignment = 'top', width, } = props; const headerOptions = noStyle ? {} : { bold: true, color: 'blue', ...props.headerOptions }; const borderStyle = noStyle ? 'none' : (props.borderStyle ?? 'all'); const borderColor = noStyle ? undefined : props.borderColor; const borderProps = { color: borderColor }; const titleOptions = noStyle ? {} : props.titleOptions; const processedData = maybeStripAnsi(sortData(filter ? data.filter((row) => filter(row)) : data, sort), noStyle); const tableWidth = width ? determineConfiguredWidth(width) : undefined; const config = { borderStyle, columns: props.columns ?? allKeysInCollection(data), data: processedData, headerOptions, horizontalAlignment, maxWidth: tableWidth ?? determineConfiguredWidth(maxWidth), overflow, padding, trimWhitespace, verticalAlignment, width: tableWidth, }; const headings = getHeadings(config); const columns = getColumns(config, headings); // check for duplicate columns const columnKeys = columns.map((c) => c.key); const duplicates = columnKeys.filter((c, i) => columnKeys.indexOf(c) !== i); if (duplicates.length > 0) { throw new Error(`Duplicate columns found: ${duplicates.join(', ')}`); } const dataComponent = row({ borderProps, cell: Cell, skeleton: BORDER_SKELETONS[config.borderStyle].data, trimWhitespace, }); const footerComponent = row({ borderProps, cell: Skeleton, props: borderProps, skeleton: BORDER_SKELETONS[config.borderStyle].footer, }); const headerComponent = row({ borderProps, cell: Skeleton, props: borderProps, skeleton: BORDER_SKELETONS[config.borderStyle].header, }); const { headerFooter } = BORDER_SKELETONS[config.borderStyle]; const headerFooterComponent = headerFooter ? row({ borderProps, cell: Skeleton, props: borderProps, skeleton: headerFooter, }) : () => false; const headingComponent = row({ borderProps, cell: Header, props: config.headerOptions, skeleton: BORDER_SKELETONS[config.borderStyle].heading, }); const separatorComponent = row({ borderProps, cell: Skeleton, props: borderProps, skeleton: BORDER_SKELETONS[config.borderStyle].separator, }); return { columns, config, dataComponent, footerComponent, headerComponent, headerFooterComponent, headingComponent, headings, processedData, separatorComponent, title, titleOptions, }; } export function Table(props) { const { columns, config, dataComponent, footerComponent, headerComponent, headerFooterComponent, headingComponent, headings, processedData, separatorComponent, title, titleOptions, } = setupTable(props); return (React.createElement(Box, { flexDirection: "column", width: determineWidthToUse(columns, config.maxWidth, config.width) }, title ? React.createElement(Text, { ...titleOptions }, title) : null, headerComponent({ columns, data: {}, key: 'header' }), headingComponent({ columns, data: headings, key: 'heading' }), headerFooterComponent({ columns, data: {}, key: 'footer' }), processedData.map((row, index) => { // Calculate the hash of the row based on its value and position const key = `row-${sha1(row)}-${index}`; // Construct a row. return (React.createElement(Box, { key: key, flexDirection: "column" }, separatorComponent({ columns, data: {}, key: `separator-${key}` }), dataComponent({ columns, data: row, key: `data-${key}` }))); }), footerComponent({ columns, data: {}, key: 'footer' }))); } /** * Constructs a Row element from the configuration. */ function row(config) { // This is a component builder. We return a function. const { borderProps, skeleton, trimWhitespace } = config; return (props) => { const data = props.columns.map((column, colI) => { const { horizontalAlignment, overflow, padding, verticalAlignment, width } = column; const value = props.data[column.column]; if (value === undefined || value === null) { const key = `${props.key}-empty-${column.key}`; return (React.createElement(config.cell, { key: key, column: colI, ...config.props }, skeleton.line.repeat(width))); } const key = `${props.key}-cell-${column.key}`; const { marginLeft, marginRight, text } = formatTextWithMargins({ horizontalAlignment, overflow, padding, trimWhitespace, value, width, }); const alignItems = verticalAlignment === 'top' ? 'flex-start' : verticalAlignment === 'center' ? 'center' : 'flex-end'; return (React.createElement(config.cell, { key: key, column: colI, alignItems, ...config.props }, `${skeleton.line.repeat(marginLeft)}${text}${skeleton.line.repeat(marginRight)}`)); }); const height = data.map((d) => d.props.children.split('\n').length).reduce((a, b) => Math.max(a, b), 0); const elements = intersperse((i) => { const key = `${props.key}-hseparator-${i}`; // The horizontal separator. return (React.createElement(Skeleton, { key: key, height: height, ...borderProps }, skeleton.cross)); }, data); return (React.createElement(Box, { flexDirection: "row" }, React.createElement(Skeleton, { height: height, ...borderProps }, skeleton.left), ...elements, React.createElement(Skeleton, { height: height, ...borderProps }, skeleton.right))); }; } /** * Renders the header of a table. */ export function Header(props) { const { children, ...rest } = props; return React.createElement(Text, { ...rest }, children); } /** * Renders a cell in the table. */ export function Cell(props) { return (React.createElement(Box, { ...props }, React.createElement(Text, null, props.children))); } /** * Renders the scaffold of the table. */ export function Skeleton(props) { const { children, ...rest } = props; // repeat Text component height times const texts = Array.from({ length: props.height ?? 1 }, (_, i) => (React.createElement(Text, { key: i, ...rest }, children))); return React.createElement(Box, { flexDirection: "column" }, texts); } /** * Return a custom WriteStream that captures the frames written to stdout. * This allows us to avoid an issue where Ink rerenders the component twice * because it uses ansiEscapes.clearTerminal, which doesn't seem to have * the desired effect in powershell. * * Implementation inspired by https://github.com/vadimdemedes/ink/blob/master/test/helpers/create-stdout.ts */ const createStdout = ({ columns }) => { // eslint-disable-next-line unicorn/prefer-event-target const stdout = new EventEmitter(); // Override the rows so that ink doesn't clear the entire terminal when // unmounting the component and the height of the output is greater than // the height of the terminal // https://github.com/vadimdemedes/ink/blob/v5.0.1/src/ink.tsx#L174 // This might be a bad idea but it works. stdout.rows = 10_000; stdout.columns = columns; const frames = []; stdout.write = (data) => { frames.push(data); return true; }; stdout.lastFrame = () => frames .filter((f) => { const stripped = stripAnsi(f); return stripped !== '' && stripped !== '\n'; }) .at(-1) ?? ''; return stdout; }; class Output { stream; constructor(opts) { this.stream = createStdout(opts); } printLastFrame() { process.stdout.write(`${this.stream.lastFrame()}\n`); } } function renderPlainTable(props) { const { columns, headings, processedData, title } = setupTable(props); if (title) console.log(title); // Process header columns the same way as data rows to handle multi-line headers const headerColumnTexts = columns.map((column) => { const { horizontalAlignment, overflow, padding, width } = column; const { marginLeft, marginRight, text } = formatTextWithMargins({ horizontalAlignment, overflow, padding, trimWhitespace: props.trimWhitespace, value: headings[column.column] ?? column.column, width, }); // Split the formatted text into lines const lines = text.split('\n'); return lines.map((line) => `${' '.repeat(marginLeft)}${line.trimStart()}${' '.repeat(marginRight)}`); }); // Find the maximum number of lines in any header column const maxHeaderLines = Math.max(...headerColumnTexts.map((col) => col.length)); // Pad all header columns to have the same number of lines const paddedHeaderColumns = headerColumnTexts.map((col, colIndex) => { const column = columns[colIndex]; while (col.length < maxHeaderLines) { col.push(' '.repeat(column.width)); // Add empty lines } return col; }); // Print each line of the header for (let lineIndex = 0; lineIndex < maxHeaderLines; lineIndex++) { const lineToPrint = paddedHeaderColumns.map((col) => col[lineIndex]).join(''); console.log(lineToPrint); } // Calculate total width for the separator line const totalWidth = columns.reduce((acc, column) => acc + column.width, 0); console.log('-'.repeat(totalWidth)); for (const row of processedData) { // Process all columns and get their formatted text const columnTexts = columns.map((column) => { const { horizontalAlignment, overflow, padding, width } = column; const value = row[column.column]; if (value === undefined || value === null) { return [' '.repeat(width)]; // Single line of spaces } const { marginLeft, marginRight, text } = formatTextWithMargins({ horizontalAlignment, overflow, padding, trimWhitespace: props.trimWhitespace, value, width, }); // Split the formatted text into lines const lines = text.split('\n'); return lines.map((line) => `${' '.repeat(marginLeft)}${line.trimStart()}${' '.repeat(marginRight)}`); }); // Find the maximum number of lines in any column const maxLines = Math.max(...columnTexts.map((col) => col.length)); // Pad all columns to have the same number of lines const paddedColumns = columnTexts.map((col, colIndex) => { const column = columns[colIndex]; while (col.length < maxLines) { col.push(' '.repeat(column.width)); // Add empty lines } return col; }); // Print each line of the row for (let lineIndex = 0; lineIndex < maxLines; lineIndex++) { const lineToPrint = paddedColumns.map((col) => col[lineIndex]).join(''); console.log(lineToPrint); } } console.log(); } /** * Prints a table based on the provided options. If the data length exceeds 10,000 entries, * the table is rendered in a non-styled format to avoid memory issues. * * @template T - A generic type that extends a record with string keys and unknown values. * @param {TableOptions<T>} options - The options for rendering the table, including data and other configurations. * @returns {void} */ export function printTable(options) { const limit = Number.parseInt(env.OCLIF_TABLE_LIMIT ?? env.SF_TABLE_LIMIT ?? '10000', 10) ?? 10_000; if (options.data.length >= limit || shouldUsePlainTable()) { renderPlainTable(options); return; } const output = new Output({ columns: options.maxWidth === 'none' ? Infinity : getColumnWidth() }); const instance = render(React.createElement(Table, { ...options }), { stdout: output.stream }); instance.unmount(); output.printLastFrame(); } /** * Generates a table as a string based on the provided options. * * @template T - A generic type extending a record with string keys and unknown values. * @param {TableOptions<T>} options - The options to configure the table. * @returns {string} The rendered table as a string. */ export function makeTable(options) { const output = new Output({ columns: options.maxWidth === 'none' ? Infinity : getColumnWidth() }); const instance = render(React.createElement(Table, { ...options }), { stdout: output.stream }); instance.unmount(); return output.stream.lastFrame() ?? ''; } function Container(props) { return (React.createElement(Box, { flexWrap: "wrap", flexDirection: props.direction ?? 'row', ...props }, props.children)); } /** * Prints multiple tables to the console. * * @template T - An array of records where each record represents a table. * @param {Object.<keyof T, TableOptions<T[keyof T]>>} tables - An object containing table options for each table. * @param {Omit<ContainerProps, 'children'>} [options] - Optional container properties excluding 'children'. * @throws {Error} Throws an error if the total number of rows across all tables exceeds 10,000. * @throws {Error} Throws an error if any of the tables have `maxWidth: "none"`. */ export function printTables(tables, options) { if (tables.reduce((acc, table) => acc + table.data.length, 0) > 10_000) { throw new Error('The data is too large to print multiple tables. Please use `printTable` instead.'); } if (tables.some((table) => table.maxWidth === 'none')) { throw new Error('printTables does not support `maxWidth: "none". Please use `printTable` instead.'); } const output = new Output({ columns: getColumnWidth() }); const leftMargin = options?.marginLeft ?? options?.margin ?? 0; const rightMargin = options?.marginRight ?? options?.margin ?? 0; const columns = getColumnWidth() - (leftMargin + rightMargin); const processed = tables.map((table) => ({ ...table, // adjust maxWidth to account for margin and columnGap maxWidth: determineConfiguredWidth(table.maxWidth, columns) - (options?.columnGap ?? 0) * tables.length, })); const instance = render(React.createElement(Container, { ...options }, processed.map((table) => (React.createElement(Table, { key: sha1(table), ...table })))), { stdout: output.stream }); instance.unmount(); output.printLastFrame(); }