Files
beardrive/internal/webapp/frontend/src/components/FileView.tsx
T
Snow Lee (Sungwon)andGitHub 119d4abf79 feat(webapp): render .csv/.tsv as a table instead of a wall of monospace (BEA-74) (#124)
A .csv already previewed — as raw text in a <pre>, columns lining up only
if the file happened to be padded. It now renders as an HTML table with the
first row as a header.

The parser is a new pure lib/csv.ts (~50 lines of RFC 4180: quoted
delimiters, "" as a literal quote, newlines inside quotes), so no
papaparse. It never throws: null means "not a table" — an unterminated
quote, or a file with no delimiter at all — and the caller falls back to
the very <pre> it renders today. That fallback is structural rather than a
second code path, because TextView gained a `delim` prop instead of a new
component: it also keeps the ["text", fileURL] query key a restore
invalidates and the retry:false a pinned ?v= version needs.

.tsv is new here — it used to fall through to SniffView and render as
text. The delimiter comes from the extension, never from sniffing.

Big files are capped at 5,000 rows with the count stated on screen
(virtualization is out of scope). Wide files scroll inside .csvbox, whose
rules are scoped under that class on purpose: the file pane carries
.markdown, and the plain .markdown table rules — including the ≤900px one
that turns a table into its own scroller — would otherwise out-specify a
bare .csvview and give the page two nested scrollers.

Not doing: sorting, filtering, search, editing, XLSX.
2026-08-11 00:22:18 +09:00

338 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { useTextAt } from "../hooks/useBlob";
import {
CSV_EXT,
HTML_EXT,
IMG_EXT,
MD_EXT,
PDF_EXT,
TEXT_EXT,
humanSize,
joinPath,
whoChanged,
} from "../util";
import { CSV_ROWS, parseDelimited, type Csv } from "../lib/csv";
export function FileView(props: {
apiBase: string;
path: string;
// Pinned to one past version by content hash (?v=), otherwise current.
version?: string;
heatMap: HeatMap | null;
flatFiles: Node[];
onOpenFile: (path: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
}) {
const { apiBase, path, version, onMeta } = props;
// A version is served by content hash; ?name= is what makes the server
// set a real Content-Type, so images and text render instead of
// downloading as octet-stream.
const fileURL = version
? apiBase + "blob?sha=" + version + "&name=" + encodeURIComponent(path)
: apiBase + "file?path=" + encodeURIComponent(path);
useEffect(() => () => onMeta(""), [path, onMeta]); // leaving a file clears its meta line
if (MD_EXT.test(path)) return <MarkdownView {...props} />;
if (HTML_EXT.test(path)) {
// Rendered, not shown as source — inside a sandboxed iframe so synced
// HTML never runs with the hub origin's session (the server also
// stamps the response with a sandbox CSP; this is belt and braces).
return (
<iframe
className="htmlview"
sandbox="allow-scripts"
src={fileURL}
title={path}
onLoad={props.onRendered}
/>
);
}
if (PDF_EXT.test(path)) {
// The browser's own viewer, streaming — no byte cap needed, nothing is
// held in JS memory. Deliberately NOT sandboxed: the PDF viewer is not
// this page's JS realm, so it can't reach the hub API or its cookies,
// and sandbox without allow-same-origin breaks Firefox's pdf.js.
return <iframe className="pdfview" src={fileURL} title={path} onLoad={props.onRendered} />;
}
if (IMG_EXT.test(path)) {
return <ImgView src={fileURL} alt={path} version={version} onRendered={props.onRendered} />;
}
// Same component as plain text on purpose: the fallback for a file the
// parser can't make a table of is then the very JSX it already renders,
// not a second code path to keep in sync.
if (CSV_EXT.test(path)) {
return <TextView {...props} fileURL={fileURL} delim={/\.tsv$/i.test(path) ? "\t" : ","} />;
}
if (TEXT_EXT.test(path)) return <TextView {...props} fileURL={fileURL} />;
// No extension we recognize: decide on the bytes instead of giving up.
return <SniffView {...props} fileURL={fileURL} />;
}
/* The fallthrough: one fetch, then text / binary / too-large. Only files
that used to show the dead "No preview" card get here, so nothing that
already previewed pays for the extra request. */
function SniffView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
const { apiBase, path, version, fileURL, onRendered } = props;
// The ["text", url] family is what a restore invalidates (Browser.tsx);
// an immutable ["blob", …] key on a live path would go stale after a
// teammate's edit. A ?v= URL is content-addressed, so it can be pinned.
const { data, error } = useTextAt(fileURL, ["text", fileURL], true, !!version);
useEffect(() => {
if (data) onRendered?.();
}, [data, onRendered]);
if (error) return <LoadError version={version} err={error as Error} />;
if (!data) return null;
if (data.kind === "text")
return (
<pre className="plain" key={path}>
{data.text}
</pre>
);
return (
<FileCard apiBase={apiBase} path={path} version={version} fileURL={fileURL}>
{data.kind === "too-large"
? `Too large to preview (${humanSize(data.size)}).`
: "No preview for this file type."}
</FileCard>
);
}
function FileCard(props: {
apiBase: string;
path: string;
version?: string;
fileURL: string;
children: React.ReactNode;
}) {
const { apiBase, path, version, fileURL } = props;
return (
<div className="filecard">
<div className="name">{path.split("/").pop()}</div>
<p>{props.children}</p>
<a
className="btn"
download
href={
version ? fileURL + "&download=1" : apiBase + "download?path=" + encodeURIComponent(path)
}
>
Download
</a>
</div>
);
}
function MarkdownView(props: Parameters<typeof FileView>[0]) {
const { apiBase, path, version, heatMap, flatFiles, onOpenFile, onMeta, onRendered } = props;
const { data: doc, error } = useQuery({
queryKey: ["render", apiBase, path, version || ""],
queryFn: () =>
getJSON<RenderDoc>(
apiBase + "render?path=" + encodeURIComponent(path) + (version ? "&sha=" + version : ""),
),
// A blob that isn't there will not appear on a retry, and the retry's
// delay is a blank pane the reader has no explanation for.
retry: version ? false : undefined,
});
// Rewrite the HTML BEFORE rendering (relative image sources, external
// link targets) rather than patching the live DOM afterwards: React owns
// the dangerouslySetInnerHTML subtree and may re-apply the markup on any
// update, silently discarding post-commit DOM patches. Link navigation
// is delegated on the container for the same reason.
const html = useMemo(
() => (doc ? transformHTML(doc.html, path, apiBase) : ""),
[doc, path, apiBase],
);
useEffect(() => {
if (!doc) return;
const parts: string[] = [];
// Guard on the raw fields, not on whoChanged's result: it answers
// "unknown" rather than "" , and plain-folder mode (no identity at
// all) has always printed nothing here.
if (doc.user_name || doc.user || doc.author) {
parts.push(whoChanged(doc) + (doc.device ? " on " + doc.device : ""));
}
if (doc.time) parts.push(new Date(doc.time).toLocaleString());
// Read counts belong to the path, not to one version — showing them
// beside content the banner just called historical reads as if they
// counted views of these bytes.
const he = version ? null : heatMap && heatMap[doc.path];
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
onMeta(parts.join(" · "));
onRendered?.();
}, [doc, version, heatMap, onMeta, onRendered]);
if (error) return <LoadError version={version} err={error as Error} />;
if (!doc) return null;
// Server-rendered, server-sanitized markdown — same trust model as the
// classic app assigning innerHTML.
return (
<div
dangerouslySetInnerHTML={{ __html: html }}
onClick={(e) => handleLinkClick(e, path, flatFiles, onOpenFile)}
/>
);
}
/* Delegated click handling for rendered-markdown links: wiki: targets
resolve by basename, relative links resolve against the current file's
folder, everything else keeps its native behavior. */
function handleLinkClick(
e: React.MouseEvent,
p: string,
flatFiles: Node[],
openFile: (path: string) => void,
) {
const a = (e.target as HTMLElement).closest("a");
if (!a || !(e.currentTarget as HTMLElement).contains(a)) return;
const href = a.getAttribute("href") || "";
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
if (href.startsWith("wiki:")) {
e.preventDefault();
openWikilink(decodeURIComponent(href.slice(5)), flatFiles, openFile);
} else if (!/^([a-z]+:|\/|#)/i.test(href)) {
e.preventDefault();
openFile(joinPath(dir, decodeURIComponent(href)));
}
}
/* String-level rewrite of the server's HTML: relative image sources point
at the file API, external links open in a new tab. */
function transformHTML(html: string, p: string, apiBase: string): string {
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
const fileURL = (path: string) => apiBase + "file?path=" + encodeURIComponent(path);
const parsed = new DOMParser().parseFromString(html, "text/html");
for (const img of parsed.querySelectorAll("img")) {
const src = img.getAttribute("src") || "";
// An inline SVG is a document, not a picture. goldmark's IsDangerousURL
// admits any `data:image/…` URL for an image, and svg+xml is the one
// member of that family a browser parses as markup — the same property
// the server's sandboxInline walls off. Raster data: URLs stay: they are
// inert and people do paste them into markdown.
if (/^\s*data:image\/svg/i.test(src)) img.removeAttribute("src");
else if (!/^([a-z]+:|\/)/i.test(src)) img.setAttribute("src", fileURL(joinPath(dir, src)));
}
for (const a of parsed.querySelectorAll("a")) {
const href = a.getAttribute("href") || "";
// goldmark's data: allowance exists for IMAGES and is applied to <a> as
// well, so a rendered document can mount a link whose target is an
// attacker-authored document. Browsers refuse a top-level data:
// navigation today, which is the only reason this is defence in depth
// rather than a live exploit — and it is not a defence this app should be
// borrowing. A markdown link is never legitimately a data: URL.
if (/^\s*data:/i.test(href)) a.removeAttribute("href");
else if (/^https?:/i.test(href)) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
return parsed.body.innerHTML;
}
function ImgView(props: { src: string; alt: string; version?: string; onRendered?: () => void }) {
const [failed, setFailed] = useState(false);
if (failed) return <LoadError version={props.version} err={new Error("could not be loaded")} />;
return (
<img src={props.src} alt={props.alt} onLoad={props.onRendered} onError={() => setFailed(true)} />
);
}
/* A missing current file is a server problem worth quoting; a missing
version is almost always a bad ?v= in a hand-edited or stale URL, which
the server's "no such version" wording does not explain. */
function LoadError({ version, err }: { version?: string; err: Error }) {
return (
<div className="empty">
{version ? "That version isn't available." : "Could not load file: " + err.message}
</div>
);
}
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string; delim?: string }) {
const { path, version, fileURL, delim, onRendered } = props;
const { data, error } = useQuery({
queryKey: ["text", fileURL],
queryFn: async () => {
const r = await fetch(fileURL);
if (!r.ok) throw new Error(await r.text());
return r.text();
},
retry: version ? false : undefined,
});
useEffect(() => {
if (data != null) onRendered?.();
}, [data, onRendered]);
// null = not usefully delimited (or no delimiter asked for): fall through
// to the plain-text view below.
const csv = useMemo(
() => (delim && data != null ? parseDelimited(data, delim, CSV_ROWS) : null),
[data, delim],
);
if (error) return <LoadError version={version} err={error as Error} />;
if (data == null) return null;
if (csv) return <CsvTable csv={csv} key={path} />;
return (
<pre className="plain" key={path}>
{data}
</pre>
);
}
/* Plain <table> — no sorting, filtering or search, so @tanstack/react-table
would only be weight. Every row is padded to the widest one so a ragged
row renders empty trailing cells instead of shifting its neighbours. */
function CsvTable({ csv }: { csv: Csv }) {
const [head, ...body] = csv.rows;
const cols = csv.rows.reduce((m, r) => Math.max(m, r.length), 0);
const idx = Array.from({ length: cols }, (_, i) => i);
return (
<>
<div className="csvbox">
<table className="csvview">
<thead>
<tr>
{idx.map((i) => (
<th key={i}>{head[i] ?? ""}</th>
))}
</tr>
</thead>
<tbody>
{body.map((r, i) => (
<tr key={i}>
{idx.map((j) => (
<td key={j}>{r[j] ?? ""}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{csv.truncated > 0 && (
<p className="csvnote">
showing {csv.rows.length.toLocaleString()} of{" "}
{(csv.rows.length + csv.truncated).toLocaleString()} rows Download for the rest
</p>
)}
</>
);
}
function openWikilink(target: string, flatFiles: Node[], openFile: (path: string) => void) {
const want = target.toLowerCase();
const hit =
flatFiles.find((f) => f.path.toLowerCase() === want || f.path.toLowerCase() === want + ".md") ||
flatFiles.find((f) => {
const n = f.name.toLowerCase();
return n === want || n === want + ".md";
});
if (hit) openFile(hit.path);
}