Open bot files in a preview modal instead of a new tab
Markdown rendered, CSV/TSV as a table, images, PDFs, audio, video and sandboxed HTML in place, other text as code, with Download and Open in a new tab. Used by file chips, message attachments, the files modal, task files and the files browser. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
|
||||
import FilePreviewNotice from "./file-preview-notice";
|
||||
import FilePreviewText from "./file-preview-text";
|
||||
|
||||
type Props = {
|
||||
kind: FilePreviewKind;
|
||||
name: string;
|
||||
url: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The preview itself, by kind. Media the browser draws itself (images,
|
||||
* PDFs, audio, video) load from the file's URL; text kinds are read into
|
||||
* the page (`FilePreviewText`). Nothing here runs the file: an SVG shows
|
||||
* through `<img>`, which runs no script, and an HTML page in a frame
|
||||
* sandboxed twice (the attribute here and the `sandbox` CSP the media
|
||||
* server sends with it, `servedFileHeaders`): no script, no forms, an
|
||||
* origin of its own.
|
||||
*/
|
||||
export default function FilePreviewBody({ kind, name, url, size }: Props) {
|
||||
if (kind == "image") {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full items-center justify-center p-4 overflow-auto bg-sunken">
|
||||
<Img src={url} alt={name} className="max-w-full max-h-full object-contain" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind == "pdf") {
|
||||
return <iframe src={url} title={name} className="block grow w-full border-0 bg-paper" />;
|
||||
}
|
||||
|
||||
if (kind == "html") {
|
||||
return <iframe src={url} title={name} sandbox="" referrerPolicy="no-referrer" className="block grow w-full border-0 bg-paper" />;
|
||||
}
|
||||
|
||||
if (kind == "audio") {
|
||||
return (
|
||||
<Stack className="grow w-full items-center justify-center p-6">
|
||||
<audio src={url} controls preload="metadata" className="w-full max-w-xl" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind == "video") {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full items-center justify-center p-4 bg-sunken">
|
||||
<video src={url} controls preload="metadata" className="max-w-full max-h-full" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind == "none") {
|
||||
return <FilePreviewNotice message="This kind of file can't be previewed here. Download it to open it." />;
|
||||
}
|
||||
|
||||
return <FilePreviewText kind={kind} url={url} size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A plain text file in the preview, as it is, on the sunken surface with
|
||||
* twui's copy button.
|
||||
*/
|
||||
export default function FilePreviewCode({ text }: Props) {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full overflow-auto p-4 items-stretch">
|
||||
<CodeBlock
|
||||
singleBlock
|
||||
backgroundColor="var(--color-sunken)"
|
||||
wrapperProps={{
|
||||
className:
|
||||
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
|
||||
}}
|
||||
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10 whitespace-pre-wrap wrap-anywhere"
|
||||
>
|
||||
{text}
|
||||
</CodeBlock>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import isPlainClick from "@/src/utils/is-plain-click";
|
||||
import FilePreviewModal from "./file-preview-modal";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
url: string;
|
||||
media_type?: string | null;
|
||||
size?: number;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A link to a file that opens it in the preview (`FilePreviewModal`) on a
|
||||
* plain click. It stays a real link to the bytes, so a middle click or
|
||||
* Ctrl/Cmd click still opens the file in a new tab.
|
||||
*/
|
||||
export default function FilePreviewLink({ name, url, media_type, size, title, className, children }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title || `Preview ${name}`}
|
||||
onClick={(event) => {
|
||||
if (!isPlainClick({ event })) return;
|
||||
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
{open ? (
|
||||
<FilePreviewModal
|
||||
name={name}
|
||||
url={url}
|
||||
media_type={media_type}
|
||||
size={size}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import H5 from "@moduletrace/twui/layout/H5";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
const HeadingClassName = "my-0! tracking-normal! font-bold text-ink";
|
||||
|
||||
/**
|
||||
* A message's markdown elements, with headings sized for a document
|
||||
* rather than a chat line.
|
||||
*/
|
||||
const DocumentComponents: Components = {
|
||||
...MessageMarkdownComponents,
|
||||
h1: ({ children }) => <H2 className={twMerge(HeadingClassName, "text-2xl! leading-8! mt-2!")}>{children}</H2>,
|
||||
h2: ({ children }) => <H3 className={twMerge(HeadingClassName, "text-xl! leading-7! mt-2!")}>{children}</H3>,
|
||||
h3: ({ children }) => <H4 className={twMerge(HeadingClassName, "text-[17px]! leading-6! mt-1!")}>{children}</H4>,
|
||||
h4: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-[15px]! leading-6!")}>{children}</H5>,
|
||||
h5: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5!")}>{children}</H5>,
|
||||
h6: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5! text-ink-secondary")}>{children}</H5>,
|
||||
};
|
||||
|
||||
/**
|
||||
* A markdown file rendered in the preview, through the same elements as a
|
||||
* bot's messages (raw HTML never rendered), in a readable column.
|
||||
*/
|
||||
export default function FilePreviewMarkdown({ text }: Props) {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full overflow-auto items-center">
|
||||
<Stack className="w-full max-w-[760px] gap-3! items-stretch px-6 py-5">
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={DocumentComponents}>
|
||||
{text}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Download, SquareArrowOutUpRight } from "lucide-react";
|
||||
import filePreviewKind from "@/src/utils/file-preview-kind";
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import FilePreviewBody from "./file-preview-body";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
url: string;
|
||||
media_type?: string | null;
|
||||
size?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ActionClassName =
|
||||
"shrink-0 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] leading-[18px] text-ink-secondary hover:text-ink rounded-default hover:bg-sunken no-underline";
|
||||
|
||||
/**
|
||||
* A file over the page: markdown rendered, a CSV as a table, an image,
|
||||
* PDF, audio or video in place, other text as code, with ways to download
|
||||
* it or open it in a new tab. A file it can't show offers those only.
|
||||
*/
|
||||
export default function FilePreviewModal({
|
||||
name,
|
||||
url,
|
||||
media_type,
|
||||
size,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const kind = filePreviewKind({ name, media_type });
|
||||
const base_name = name.split("/").pop() || name;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
setOpen={(next_open) => {
|
||||
if (!next_open) onClose();
|
||||
}}
|
||||
className="max-w-[95vw]! w-[95vw] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
|
||||
>
|
||||
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
|
||||
<Stack className="min-w-0 grow gap-0! items-start">
|
||||
<H2
|
||||
title={name}
|
||||
className="text-base! leading-6! tracking-normal! font-bold text-ink truncate w-full"
|
||||
>
|
||||
{base_name}
|
||||
</H2>
|
||||
{size ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{formatBytes({ bytes: size })}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Link
|
||||
href={url}
|
||||
download={base_name}
|
||||
title={`Download ${base_name}`}
|
||||
className={ActionClassName}
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
<Span className="hidden sm:inline">Download</Span>
|
||||
</Link>
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open in a new tab"
|
||||
aria-label="Open in a new tab"
|
||||
className={ActionClassName}
|
||||
>
|
||||
<SquareArrowOutUpRight size={16} aria-hidden />
|
||||
</Link>
|
||||
</Row>
|
||||
<FilePreviewBody
|
||||
kind={kind}
|
||||
name={base_name}
|
||||
url={url}
|
||||
size={size}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the preview says instead of a file it doesn't show.
|
||||
*/
|
||||
export default function FilePreviewNotice({ message }: Props) {
|
||||
return (
|
||||
<Stack className="grow w-full items-center justify-center p-6">
|
||||
<P noMargin className="text-sm leading-5 text-ink-secondary text-center max-w-md">
|
||||
{message}
|
||||
</P>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
header?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One cell of a previewed CSV, its text kept to one readable width.
|
||||
*/
|
||||
export default function FilePreviewTableCell({ value, header }: Props) {
|
||||
const className = twMerge(
|
||||
"px-3 py-1.5 max-w-80 text-left align-top whitespace-pre-wrap wrap-break-word border-0 border-b border-r border-solid border-hairline",
|
||||
header && "font-bold border-hairline-strong",
|
||||
);
|
||||
|
||||
if (header) return <th className={className}>{value}</th>;
|
||||
|
||||
return <td className={className}>{value}</td>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import FilePreviewTableCell from "./file-preview-table-cell";
|
||||
|
||||
type Props = {
|
||||
cells: string[];
|
||||
header?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of a previewed CSV.
|
||||
*/
|
||||
export default function FilePreviewTableRow({ cells, header }: Props) {
|
||||
return (
|
||||
<tr>
|
||||
{cells.map((value, index) => (
|
||||
<FilePreviewTableCell key={index} value={value} header={header} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import parseCsv from "@/src/utils/parse-csv";
|
||||
import FilePreviewNotice from "./file-preview-notice";
|
||||
import FilePreviewTableRow from "./file-preview-table-row";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
delimiter: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A CSV or TSV file as a table: its first row the header (kept in view
|
||||
* while scrolling), up to `AppData.FilePreviewMaxTableRows` rows. Cells
|
||||
* are text only. Tables have no children-based twui equivalent, so they're
|
||||
* styled elements, as in a message.
|
||||
*/
|
||||
export default function FilePreviewTable({ text, delimiter }: Props) {
|
||||
const { rows, truncated } = useMemo(
|
||||
() => parseCsv({ text, delimiter, max_rows: AppData.FilePreviewMaxTableRows + 1 }),
|
||||
[text, delimiter],
|
||||
);
|
||||
|
||||
if (!rows.length) return <FilePreviewNotice message="This file is empty." />;
|
||||
|
||||
const [header, ...body] = rows;
|
||||
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full items-stretch gap-0!">
|
||||
<Stack className="grow min-h-0 w-full overflow-auto items-start">
|
||||
<table className="text-[13px] leading-5 text-ink border-separate border-spacing-0">
|
||||
<thead className="sticky top-0 bg-surface">
|
||||
<FilePreviewTableRow cells={header!} header />
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((cells, index) => (
|
||||
<FilePreviewTableRow key={index} cells={cells} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Stack>
|
||||
<P
|
||||
noMargin
|
||||
className="shrink-0 px-5 py-2 text-[12px] leading-4 text-ink-secondary border-0 border-t border-solid border-hairline"
|
||||
>
|
||||
{truncated
|
||||
? `Showing the first ${body.length} rows. Download the file for the rest.`
|
||||
: `${body.length} ${body.length == 1 ? "row" : "rows"}`}
|
||||
</P>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import useFileText from "@/src/hooks/use-file-text";
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
|
||||
import FilePreviewCode from "./file-preview-code";
|
||||
import FilePreviewMarkdown from "./file-preview-markdown";
|
||||
import FilePreviewNotice from "./file-preview-notice";
|
||||
import FilePreviewTable from "./file-preview-table";
|
||||
|
||||
type Props = {
|
||||
kind: FilePreviewKind;
|
||||
url: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A text file read into the preview: markdown rendered, CSV or TSV as a
|
||||
* table, anything else as code.
|
||||
*/
|
||||
export default function FilePreviewText({ kind, url, size }: Props) {
|
||||
const { text, loading, error, too_large } = useFileText({ url, size });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack className="grow w-full items-center justify-center">
|
||||
<Loading />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (too_large) {
|
||||
return (
|
||||
<FilePreviewNotice
|
||||
message={`This file is bigger than ${formatBytes({ bytes: AppData.FilePreviewMaxTextBytes })}, too big to preview. Download it to open it.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || text == null) {
|
||||
return <FilePreviewNotice message={error || "The file couldn't be read."} />;
|
||||
}
|
||||
|
||||
if (kind == "markdown") return <FilePreviewMarkdown text={text} />;
|
||||
if (kind == "csv") return <FilePreviewTable text={text} delimiter="," />;
|
||||
if (kind == "tsv") return <FilePreviewTable text={text} delimiter={"\t"} />;
|
||||
|
||||
return <FilePreviewCode text={text} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import FilePreviewBody from "./file-preview-body";
|
||||
import FilePreviewLink from "./file-preview-link";
|
||||
import FilePreviewMarkdown from "./file-preview-markdown";
|
||||
import FilePreviewTable from "./file-preview-table";
|
||||
|
||||
test("a markdown file renders as a document, raw HTML left out", () => {
|
||||
const html = renderToString(
|
||||
<FilePreviewMarkdown text={"# Report\n\nSome **bold** text.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n<script>alert(1)</script>"} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Report");
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("font-bold");
|
||||
expect(html).toContain("<table");
|
||||
expect(html).not.toContain("<script");
|
||||
});
|
||||
|
||||
test("a CSV renders as a table with its header row", () => {
|
||||
const html = renderToString(<FilePreviewTable text={'name,city\n"Smith, J",Lagos\n'} delimiter="," />);
|
||||
|
||||
expect(html).toContain("<thead");
|
||||
expect(html).toContain("<th");
|
||||
expect(html).toContain("Smith, J");
|
||||
expect(html).toContain("1 row");
|
||||
});
|
||||
|
||||
test("a long CSV says it was cut", () => {
|
||||
const rows = Array.from({ length: AppData.FilePreviewMaxTableRows + 5 }, (_, i) => `${i}`).join("\n");
|
||||
const html = renderToString(<FilePreviewTable text={`n\n${rows}`} delimiter="," />);
|
||||
|
||||
expect(html).toContain(`Showing the first ${AppData.FilePreviewMaxTableRows} rows`);
|
||||
});
|
||||
|
||||
test("media previews load from the file's URL, unknown files offer a download", () => {
|
||||
expect(renderToString(<FilePreviewBody kind="image" name="a.png" url="/media/1" />)).toContain('src="/media/1"');
|
||||
expect(renderToString(<FilePreviewBody kind="pdf" name="a.pdf" url="/media/2" />)).toContain("<iframe");
|
||||
expect(renderToString(<FilePreviewBody kind="html" name="a.html" url="/media/4" />)).toContain('sandbox=""');
|
||||
expect(renderToString(<FilePreviewBody kind="none" name="a.zip" url="/media/3" />)).toContain("can't be previewed");
|
||||
});
|
||||
|
||||
test("a file link stays a real link to the bytes until clicked", () => {
|
||||
const html = renderToString(
|
||||
<FilePreviewLink name="report.md" url="/media/9">
|
||||
report.md
|
||||
</FilePreviewLink>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/media/9"');
|
||||
expect(html).toContain("Preview report.md");
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { FileText, Image as ImageIcon } from "lucide-react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { TaskFile } from "@/src/types";
|
||||
import fileRef from "@/src/utils/file-ref";
|
||||
import FilePreviewLink from "./file-preview-link";
|
||||
|
||||
type Props = {
|
||||
file: TaskFile;
|
||||
@@ -15,8 +15,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One file on a task: its name, opening the bytes at `/media/<id>` (private,
|
||||
* so only someone who reaches the task can load it), and its file ref (built by `fileRef`) when it
|
||||
* One file on a task: its name, opening it in the file preview (its bytes at
|
||||
* `/media/<id>`, private, so only someone who reaches the task can load them), and its file ref (built by `fileRef`) when it
|
||||
* lives in a bot's or workspace's folder (so the file's real home is
|
||||
* reachable too).
|
||||
*/
|
||||
@@ -39,15 +39,14 @@ export default function TaskFileRow({ file, onDetach, busy }: Props) {
|
||||
) : (
|
||||
<FileText size={16} className="shrink-0 text-ink-secondary" aria-hidden />
|
||||
)}
|
||||
<Link
|
||||
href={`/media/${file.media_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={file.name || "Open the file"}
|
||||
<FilePreviewLink
|
||||
name={file.name || `File ${file.media_id}`}
|
||||
url={`/media/${file.media_id}`}
|
||||
media_type={file.media_type}
|
||||
className="grow min-w-0 text-[13px] leading-[18px] text-ink truncate hover:underline"
|
||||
>
|
||||
{file.name || `File ${file.media_id}`}
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
{file.kind == "reference" ? (
|
||||
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">reference</Span>
|
||||
) : null}
|
||||
|
||||
@@ -597,6 +597,15 @@ export const AppData = {
|
||||
* inside its frame.
|
||||
*/
|
||||
BotGraphicInlineMaxHeight: 640,
|
||||
/**
|
||||
* Largest text file (markdown, CSV, plain text) the file preview reads
|
||||
* into the page (bytes); a bigger one is only offered to download.
|
||||
*/
|
||||
FilePreviewMaxTextBytes: 2 * 1024 * 1024,
|
||||
/**
|
||||
* Most rows of a CSV the file preview draws.
|
||||
*/
|
||||
FilePreviewMaxTableRows: 1000,
|
||||
/**
|
||||
* Most questions one `ask_user` card holds.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
url: string;
|
||||
/**
|
||||
* Read nothing (a preview that isn't text).
|
||||
*/
|
||||
skip?: boolean;
|
||||
/**
|
||||
* The file's size when known, so a big one isn't fetched at all.
|
||||
*/
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type FileText = {
|
||||
text: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* Bigger than `AppData.FilePreviewMaxTextBytes`: offered to download only.
|
||||
*/
|
||||
too_large: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A file's bytes read as text for the preview, once per URL, up to
|
||||
* `AppData.FilePreviewMaxTextBytes`.
|
||||
*/
|
||||
export default function useFileText({ url, skip, size }: Params): FileText {
|
||||
const max = AppData.FilePreviewMaxTextBytes;
|
||||
const known_too_large = Boolean(size && size > max);
|
||||
const [state, setState] = useState<FileText>({
|
||||
text: null,
|
||||
loading: !skip && !known_too_large,
|
||||
error: null,
|
||||
too_large: known_too_large,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (skip || known_too_large) return;
|
||||
|
||||
let live = true;
|
||||
|
||||
setState({ text: null, loading: true, error: null, too_large: false });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
|
||||
if (!res.ok) throw new Error(`The file couldn't be read (${res.status}).`);
|
||||
|
||||
const length = Number(res.headers.get("content-length")) || 0;
|
||||
|
||||
if (length > max) {
|
||||
res.body?.cancel();
|
||||
if (live) setState({ text: null, loading: false, error: null, too_large: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
if (blob.size > max) {
|
||||
if (live) setState({ text: null, loading: false, error: null, too_large: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await blob.text();
|
||||
|
||||
if (live) setState({ text, loading: false, error: null, too_large: false });
|
||||
} catch (error: any) {
|
||||
if (live) {
|
||||
setState({
|
||||
text: null,
|
||||
loading: false,
|
||||
error: error?.message || "The file couldn't be read.",
|
||||
too_large: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [url, skip, known_too_large, max]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BotFileScopesPayload, MediaType, PublicBotFile } from "@/src/types";
|
||||
import FilePreviewLink from "@/src/components/general/file-preview-link";
|
||||
import AttachmentIcon from "./attachment-icon";
|
||||
|
||||
type Props = {
|
||||
@@ -50,8 +50,8 @@ function fetchFile({
|
||||
|
||||
/**
|
||||
* A file a bot named in a message (`fileRef`): the name it is saved under,
|
||||
* linked to its bytes at `/media/<id>` (private: only someone who reaches
|
||||
* the file's folder can load it). A folder the bot named is a `folderRef`
|
||||
* opening it in the file preview (its bytes at `/media/<id>`, private: only
|
||||
* someone who reaches the file's folder can load them). A folder the bot named is a `folderRef`
|
||||
* instead, and its chip opens the files browser. The conversation's files come with the page, so most
|
||||
* chips need no request; one that isn't among them (older than the page's
|
||||
* first page) is fetched by id. A file the user doesn't reach, or one
|
||||
@@ -85,15 +85,15 @@ export default function FileRefChip({ file_id }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${file.name}`}
|
||||
<FilePreviewLink
|
||||
name={file.name}
|
||||
url={file.url}
|
||||
media_type={file.media_type}
|
||||
size={file.size}
|
||||
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink hover:opacity-80"
|
||||
>
|
||||
<AttachmentIcon media_type={file.media_type as MediaType} />
|
||||
<Span className="truncate">{file.name}</Span>
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
@@ -6,6 +5,7 @@ import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { MediaType, PublicBotFile } from "@/src/types";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import FilePreviewLink from "@/src/components/general/file-preview-link";
|
||||
import AttachmentIcon from "./attachment-icon";
|
||||
|
||||
type Props = {
|
||||
@@ -29,15 +29,15 @@ export default function FileRow({ file, can_manage, busy, onDelete }: Props) {
|
||||
<AttachmentIcon media_type={file.media_type as MediaType} size={16} />
|
||||
</Row>
|
||||
<Stack className="min-w-0 flex-1 gap-0.5!">
|
||||
<Link
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${file.name}`}
|
||||
<FilePreviewLink
|
||||
name={file.path || file.name}
|
||||
url={file.url}
|
||||
media_type={file.media_type}
|
||||
size={file.size}
|
||||
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
|
||||
>
|
||||
{file.path || file.name}
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{[
|
||||
file.scope == "workspace" ? "Workspace" : "Bot files",
|
||||
|
||||
@@ -34,7 +34,7 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
||||
* becomes its switch button, a take-over request a button that opens the
|
||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||
* a link to that workspace, a file it saved a chip that downloads it, a
|
||||
* a link to that workspace, a file it saved a chip that previews it, a
|
||||
* folder it named a chip that opens it in the files browser, an MCP server,
|
||||
* tool, skill or hook it proposed a chip that opens the confirm card, a
|
||||
* secret it showed a masked chip, a secret it asked for a chip that opens
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { BotMessageAttachment } from "@/src/types";
|
||||
import FilePreviewLink from "@/src/components/general/file-preview-link";
|
||||
import AttachmentIcon from "./attachment-icon";
|
||||
|
||||
/**
|
||||
* One file on a message: an image as a thumbnail, anything else as a
|
||||
* named chip. Both open the file in a new tab.
|
||||
* named chip. Both open the file in the preview.
|
||||
*/
|
||||
export default function MessageAttachment({
|
||||
name,
|
||||
@@ -16,25 +16,26 @@ export default function MessageAttachment({
|
||||
}: BotMessageAttachment) {
|
||||
if (media_type == "image") {
|
||||
return (
|
||||
<Link href={url} target="_blank" title={name} className="block">
|
||||
<FilePreviewLink name={name} url={url} media_type={media_type} title={name} className="block">
|
||||
<Img
|
||||
src={thumbnail_url || url}
|
||||
alt={name}
|
||||
className="max-h-48 max-w-64 rounded-default-lg border border-solid border-hairline object-cover"
|
||||
/>
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
<FilePreviewLink
|
||||
name={name}
|
||||
url={url}
|
||||
media_type={media_type}
|
||||
title={name}
|
||||
className="flex max-w-64 items-center gap-2 rounded-default-lg border border-solid border-hairline bg-surface px-3 py-2 text-[13px] leading-[18px] text-ink no-underline hover:border-hairline-strong"
|
||||
>
|
||||
<AttachmentIcon media_type={media_type} />
|
||||
<Span className="truncate">{name}</Span>
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { MediaType, StorageEntry } from "@/src/types";
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import AttachmentIcon from "@/src/pages/admin/bots/[bot_id]/(partials)/attachment-icon";
|
||||
import FilePreviewLink from "@/src/components/general/file-preview-link";
|
||||
|
||||
type Props = {
|
||||
entry: StorageEntry;
|
||||
@@ -16,8 +17,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One entry in the open folder: a folder opens in place, a file opens in a
|
||||
* new tab (its own media record when it is one of the bot's files, else
|
||||
* One entry in the open folder: a folder opens in place, a file opens in the
|
||||
* file preview (its own media record when it is one of the bot's files, else
|
||||
* the browser's streaming route).
|
||||
*/
|
||||
export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props) {
|
||||
@@ -51,15 +52,15 @@ export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props
|
||||
{entry.name}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${entry.name}`}
|
||||
<FilePreviewLink
|
||||
name={entry.name}
|
||||
url={entry.url || ""}
|
||||
media_type={entry.media_type}
|
||||
size={entry.size}
|
||||
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
|
||||
>
|
||||
{entry.name}
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
)}
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{[
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import filePreviewKind from "./file-preview-kind";
|
||||
|
||||
test("filePreviewKind reads the extension first", () => {
|
||||
expect(filePreviewKind({ name: "report.md", media_type: "document" })).toBe("markdown");
|
||||
expect(filePreviewKind({ name: "seo/raw/Ranks.CSV" })).toBe("csv");
|
||||
expect(filePreviewKind({ name: "data.tsv" })).toBe("tsv");
|
||||
expect(filePreviewKind({ name: "chart.svg", media_type: "document" })).toBe("image");
|
||||
expect(filePreviewKind({ name: "paper.pdf" })).toBe("pdf");
|
||||
expect(filePreviewKind({ name: "page.html" })).toBe("html");
|
||||
expect(filePreviewKind({ name: "run.xml.out" })).toBe("text");
|
||||
expect(filePreviewKind({ name: "notes.json" })).toBe("text");
|
||||
expect(filePreviewKind({ name: "README" })).toBe("text");
|
||||
});
|
||||
|
||||
test("filePreviewKind falls back to the media type", () => {
|
||||
expect(filePreviewKind({ name: "photo", media_type: "image" })).toBe("image");
|
||||
expect(filePreviewKind({ name: "clip", media_type: "video" })).toBe("video");
|
||||
expect(filePreviewKind({ name: "voice", media_type: "audio" })).toBe("audio");
|
||||
expect(filePreviewKind({ name: "archive.zip", media_type: "document" })).toBe("none");
|
||||
expect(filePreviewKind({ name: ".md" })).toBe("none");
|
||||
expect(filePreviewKind({})).toBe("none");
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* How the file preview shows a file.
|
||||
*/
|
||||
export type FilePreviewKind =
|
||||
| "markdown"
|
||||
| "csv"
|
||||
| "tsv"
|
||||
| "image"
|
||||
| "pdf"
|
||||
| "html"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "text"
|
||||
| "none";
|
||||
|
||||
type Params = {
|
||||
name?: string | null;
|
||||
/**
|
||||
* The `media` type (`image`, `video`, `document`, `audio`), when known.
|
||||
*/
|
||||
media_type?: string | null;
|
||||
};
|
||||
|
||||
const ExtensionKinds: Record<string, FilePreviewKind> = {
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
mdx: "markdown",
|
||||
csv: "csv",
|
||||
tsv: "tsv",
|
||||
tab: "tsv",
|
||||
png: "image",
|
||||
jpg: "image",
|
||||
jpeg: "image",
|
||||
gif: "image",
|
||||
webp: "image",
|
||||
avif: "image",
|
||||
bmp: "image",
|
||||
ico: "image",
|
||||
svg: "image",
|
||||
pdf: "pdf",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
mp3: "audio",
|
||||
wav: "audio",
|
||||
ogg: "audio",
|
||||
oga: "audio",
|
||||
m4a: "audio",
|
||||
flac: "audio",
|
||||
aac: "audio",
|
||||
opus: "audio",
|
||||
mp4: "video",
|
||||
webm: "video",
|
||||
mov: "video",
|
||||
m4v: "video",
|
||||
ogv: "video",
|
||||
};
|
||||
|
||||
/**
|
||||
* Text read as it is (shown as code, never run: XML sources too).
|
||||
*/
|
||||
const TextExtensions = [
|
||||
"txt",
|
||||
"text",
|
||||
"log",
|
||||
"out",
|
||||
"json",
|
||||
"jsonl",
|
||||
"ndjson",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
"xml",
|
||||
"css",
|
||||
"scss",
|
||||
"js",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"ts",
|
||||
"tsx",
|
||||
"jsx",
|
||||
"py",
|
||||
"rb",
|
||||
"php",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"kt",
|
||||
"swift",
|
||||
"c",
|
||||
"h",
|
||||
"cpp",
|
||||
"hpp",
|
||||
"cs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"sql",
|
||||
"diff",
|
||||
"patch",
|
||||
"rst",
|
||||
"tex",
|
||||
"ics",
|
||||
"vtt",
|
||||
"srt",
|
||||
];
|
||||
|
||||
/**
|
||||
* Files often named without an extension that hold text.
|
||||
*/
|
||||
const TextNames = ["readme", "license", "makefile", "dockerfile", "changelog", "notes"];
|
||||
|
||||
/**
|
||||
* What the file preview does with a file, from its name first (a bot's
|
||||
* `report.md` is a document to `media`) and its media type after.
|
||||
*/
|
||||
export default function filePreviewKind({ name, media_type }: Params): FilePreviewKind {
|
||||
const base = (name || "").trim().toLowerCase().split("/").pop() || "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
const extension = dot > 0 ? base.slice(dot + 1) : "";
|
||||
|
||||
if (extension && ExtensionKinds[extension]) return ExtensionKinds[extension]!;
|
||||
if (extension && TextExtensions.includes(extension)) return "text";
|
||||
if (!extension && TextNames.includes(base)) return "text";
|
||||
|
||||
if (media_type == "image") return "image";
|
||||
if (media_type == "audio") return "audio";
|
||||
if (media_type == "video") return "video";
|
||||
|
||||
return "none";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import isPlainClick from "./is-plain-click";
|
||||
|
||||
const click = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false };
|
||||
|
||||
test("isPlainClick takes only an unmodified left click", () => {
|
||||
expect(isPlainClick({ event: click })).toBe(true);
|
||||
expect(isPlainClick({ event: { ...click, button: 1 } })).toBe(false);
|
||||
expect(isPlainClick({ event: { ...click, metaKey: true } })).toBe(false);
|
||||
expect(isPlainClick({ event: { ...click, ctrlKey: true } })).toBe(false);
|
||||
expect(isPlainClick({ event: { ...click, shiftKey: true } })).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
type Params = {
|
||||
event: {
|
||||
button: number;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A plain left click, with no key held: the one a link may take over (to
|
||||
* open a preview); a middle click or Ctrl/Cmd/Shift click still does what
|
||||
* the browser does with a link (a new tab or window).
|
||||
*/
|
||||
export default function isPlainClick({ event }: Params) {
|
||||
return event.button == 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import parseCsv from "./parse-csv";
|
||||
|
||||
test("parseCsv splits rows and cells", () => {
|
||||
expect(parseCsv({ text: "a,b\r\n1,2\n3,4\n" })).toEqual({
|
||||
rows: [
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
],
|
||||
truncated: false,
|
||||
columns: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseCsv keeps quoted delimiters, line breaks and quotes", () => {
|
||||
const { rows } = parseCsv({ text: 'name,note\n"Smith, J","said ""hi""\nthen left"\n' });
|
||||
|
||||
expect(rows).toEqual([
|
||||
["name", "note"],
|
||||
["Smith, J", 'said "hi"\nthen left'],
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseCsv pads short rows and reads tabs", () => {
|
||||
const { rows, columns } = parseCsv({ text: "a\tb\tc\n1", delimiter: "\t" });
|
||||
|
||||
expect(columns).toBe(3);
|
||||
expect(rows).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "", ""],
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseCsv stops at max_rows", () => {
|
||||
expect(parseCsv({ text: "h\n1\n2\n3\n", max_rows: 2 })).toEqual({
|
||||
rows: [["h"], ["1"]],
|
||||
truncated: true,
|
||||
columns: 1,
|
||||
});
|
||||
expect(parseCsv({ text: "h\n1\n", max_rows: 2 }).truncated).toBe(false);
|
||||
expect(parseCsv({ text: "" }).rows).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
type Params = {
|
||||
text: string;
|
||||
/**
|
||||
* `,` for CSV, a tab for TSV.
|
||||
*/
|
||||
delimiter?: string;
|
||||
/**
|
||||
* Stop after this many rows (the header included).
|
||||
*/
|
||||
max_rows?: number;
|
||||
};
|
||||
|
||||
export type ParsedCsv = {
|
||||
rows: string[][];
|
||||
/**
|
||||
* Rows were left out past `max_rows`.
|
||||
*/
|
||||
truncated: boolean;
|
||||
/**
|
||||
* The widest row's cell count; every row is padded to it.
|
||||
*/
|
||||
columns: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits CSV text (RFC 4180: quoted cells may hold delimiters, line breaks
|
||||
* and doubled quotes) into rows of cells. A leading BOM is dropped, `\r\n`
|
||||
* and `\n` both end a row, a trailing empty line adds no row, and short
|
||||
* rows are padded so every row has the same number of cells.
|
||||
*/
|
||||
export default function parseCsv({ text, delimiter = ",", max_rows }: Params): ParsedCsv {
|
||||
const source = text.charCodeAt(0) == 0xfeff ? text.slice(1) : text;
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let quoted = false;
|
||||
let truncated = false;
|
||||
let index = 0;
|
||||
|
||||
const endRow = () => {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = "";
|
||||
};
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
|
||||
if (quoted) {
|
||||
if (char == '"') {
|
||||
if (source[index + 1] == '"') {
|
||||
cell += '"';
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
quoted = false;
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '"' && cell == "") {
|
||||
quoted = true;
|
||||
} else if (char == delimiter) {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
} else if (char == "\n" || char == "\r") {
|
||||
if (char == "\r" && source[index + 1] == "\n") index++;
|
||||
|
||||
endRow();
|
||||
|
||||
if (max_rows && rows.length >= max_rows) {
|
||||
truncated = index + 1 < source.length;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
if (!truncated && (cell != "" || row.length)) endRow();
|
||||
|
||||
const columns = rows.reduce((widest, it) => Math.max(widest, it.length), 0);
|
||||
|
||||
for (const it of rows) {
|
||||
while (it.length < columns) it.push("");
|
||||
}
|
||||
|
||||
return { rows, truncated, columns };
|
||||
}
|
||||
Reference in New Issue
Block a user