@oclif/table
Version:
Display table in terminal
458 lines (457 loc) • 20.3 kB
JavaScript
/* 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();
}