mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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.
This commit is contained in:
@@ -98,9 +98,11 @@ classDiagram
|
||||
+heat.ts orphanPaths (reads whose file left the tree)
|
||||
+heat.ts placeLabels LABEL_MAX (scatter danger-dot labels)
|
||||
+sniff.ts sniffBytes BlobText MAX_BYTES
|
||||
+csv.ts parseDelimited Csv CSV_ROWS
|
||||
+utils.ts
|
||||
}
|
||||
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it"
|
||||
note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"
|
||||
|
||||
ErrorBoundary --> App : wraps the whole tree
|
||||
App --> HubApp
|
||||
@@ -112,7 +114,7 @@ classDiagram
|
||||
Browser --> components
|
||||
HubApp --> components
|
||||
components --> nav : linkProps navigate
|
||||
components --> lib : diffText groupRuns hotPathSplit placeLabels
|
||||
components --> lib : diffText groupRuns hotPathSplit placeLabels parseDelimited
|
||||
hooks --> lib : re-exports heat.ts, sniffBytes
|
||||
hooks --> api
|
||||
Browser --> hooks
|
||||
|
||||
@@ -777,3 +777,126 @@ test("an old version of an extensionless file previews the same way", async ({ p
|
||||
await page.goto(`/${pid}/sniff/LICENSE?v=${"0".repeat(64)}`);
|
||||
await expect(page.locator("#content .empty")).toContainText("That version isn't available.");
|
||||
});
|
||||
|
||||
// BEA-74: .csv/.tsv render as a table, and anything the parser can't make a
|
||||
// table of stays the plain-text view it is today.
|
||||
|
||||
const SALES_CSV = [
|
||||
`region,rep,quarter,"revenue (usd)",notes`,
|
||||
`EMEA,"Ortiz, Ana",Q1,128400,steady growth in the enterprise segment`,
|
||||
`APAC,"Chen, Wei",Q2,96250,"he said ""ship it"" on Friday"`,
|
||||
`LATAM,"Silva, Joao",Q3,74100,"two lines\nin one cell"`,
|
||||
`NA,"Baker, Sam",Q4,181900`,
|
||||
``,
|
||||
].join("\n");
|
||||
|
||||
test("csv renders as a table: quoting, embedded newlines, a ragged row", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=csv/sales.csv`, { data: SALES_CSV });
|
||||
|
||||
await page.goto(`/${pid}/csv/sales.csv`);
|
||||
const table = page.locator("#content table.csvview");
|
||||
await expect(table).toBeVisible();
|
||||
await expect(page.locator("#content pre.plain")).toHaveCount(0);
|
||||
await expect(table.locator("thead th")).toHaveCount(5);
|
||||
await expect(table.locator("thead th").nth(3)).toHaveText("revenue (usd)");
|
||||
await expect(table.locator("tbody tr")).toHaveCount(4);
|
||||
// A quoted comma is one cell, not two.
|
||||
await expect(table.locator("tbody tr").first().locator("td").nth(1)).toHaveText("Ortiz, Ana");
|
||||
// "" is one literal quote.
|
||||
await expect(table.locator("tbody tr").nth(1).locator("td").nth(4)).toHaveText(
|
||||
'he said "ship it" on Friday',
|
||||
);
|
||||
// A newline inside quotes is one cell in one row, not a second row.
|
||||
// textContent, not toHaveText: the latter normalizes away the very
|
||||
// newline this case exists to prove survived.
|
||||
expect(
|
||||
await table
|
||||
.locator("tbody tr")
|
||||
.nth(2)
|
||||
.locator("td")
|
||||
.nth(4)
|
||||
.evaluate((el) => el.textContent),
|
||||
).toBe("two lines\nin one cell");
|
||||
// The short last row keeps its columns and pads the missing one.
|
||||
const last = table.locator("tbody tr").last().locator("td");
|
||||
await expect(last).toHaveCount(5);
|
||||
await expect(last.nth(3)).toHaveText("181900");
|
||||
await expect(last.nth(4)).toHaveText("");
|
||||
});
|
||||
|
||||
test("tsv gets the same table, by extension and not by sniffing", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=csv/hosts.tsv`, {
|
||||
data: "host\trole\nalpha\tweb\nbeta\tdb\n",
|
||||
});
|
||||
await page.goto(`/${pid}/csv/hosts.tsv`);
|
||||
await expect(page.locator("#content table.csvview thead th")).toHaveCount(2);
|
||||
await expect(page.locator("#content table.csvview tbody tr")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("a csv the parser can't read falls back to today's plain text", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
// Unterminated quote: not something to guess at.
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=csv/broken.csv`, {
|
||||
data: 'a,b\n"never closed,2\n',
|
||||
});
|
||||
await page.goto(`/${pid}/csv/broken.csv`);
|
||||
await expect(page.locator("#content pre.plain")).toContainText("never closed");
|
||||
await expect(page.locator("#content table.csvview")).toHaveCount(0);
|
||||
|
||||
// No delimiter at all is prose, not a one-column table.
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=csv/prose.csv`, {
|
||||
data: "just some prose\nover two lines\n",
|
||||
});
|
||||
await page.goto(`/${pid}/csv/prose.csv`);
|
||||
await expect(page.locator("#content pre.plain")).toContainText("just some prose");
|
||||
await expect(page.locator("#content table.csvview")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a past version of a csv is a table too", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
const url = `/api/p/${pid}/upload/content?path=csv/versioned.csv`;
|
||||
await page.request.put(url, { data: "a,b\n1,2\n" });
|
||||
await page.request.put(url, { data: "a,b\n3,4\n" });
|
||||
await page.goto(`/${pid}/history/csv/versioned.csv`);
|
||||
const older = page.locator(".hentry.add");
|
||||
await expect(older).toBeVisible();
|
||||
await older.getByRole("button", { name: /^Open .* as of/ }).click();
|
||||
await page.waitForURL(new RegExp(`/${pid}/csv/versioned\\.csv\\?v=[0-9a-f]{64}$`));
|
||||
await expect(page.locator("#content table.csvview tbody")).toContainText("1");
|
||||
});
|
||||
|
||||
test("a csv past the row cap says how many rows it left out", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
const big = "n,sq\n" + Array.from({ length: 5200 }, (_, i) => `${i},${i * i}`).join("\n") + "\n";
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=csv/big.csv`, { data: big });
|
||||
await page.goto(`/${pid}/csv/big.csv`);
|
||||
await expect(page.locator("#content table.csvview tbody tr")).toHaveCount(4999); // 5,000 incl. header
|
||||
await expect(page.locator("#content .csvnote")).toContainText("showing 5,000 of 5,201 rows");
|
||||
});
|
||||
|
||||
test("a wide csv scrolls inside its own box at 390px", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
const cols = Array.from({ length: 14 }, (_, i) => `column_heading_number_${i}`);
|
||||
const wide = [cols.join(","), cols.map((_, i) => `value-${i}-with-some-length`).join(",")].join(
|
||||
"\n",
|
||||
);
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=csv/wide.csv`, { data: wide + "\n" });
|
||||
await page.setViewportSize({ width: 390, height: 780 });
|
||||
await page.goto(`/${pid}/csv/wide.csv`);
|
||||
const box = page.locator("#content .csvbox");
|
||||
await expect(box).toBeVisible();
|
||||
expect(await box.evaluate((el) => getComputedStyle(el).overflowX)).toBe("auto");
|
||||
// The box takes the sideways scroll; the page body never does.
|
||||
expect(await box.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,18 @@ 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 { HTML_EXT, IMG_EXT, MD_EXT, PDF_EXT, TEXT_EXT, humanSize, joinPath, whoChanged } from "../util";
|
||||
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;
|
||||
@@ -52,6 +63,12 @@ export function FileView(props: {
|
||||
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} />;
|
||||
@@ -239,8 +256,8 @@ function LoadError({ version, err }: { version?: string; err: Error }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
|
||||
const { path, version, fileURL, onRendered } = props;
|
||||
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 () => {
|
||||
@@ -253,8 +270,15 @@ function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
|
||||
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}
|
||||
@@ -262,6 +286,45 @@ function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 =
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
|
||||
// Excluded from tsconfig's include — it imports node: builtins, which the
|
||||
// app's DOM-only lib set does not know about.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseDelimited, CSV_ROWS } from "./csv.ts";
|
||||
|
||||
const rows = (text: string, delim = ",", cap = CSV_ROWS) =>
|
||||
parseDelimited(text, delim, cap)?.rows;
|
||||
|
||||
test("plain file: first row is the header, one cell per field", () => {
|
||||
assert.deepEqual(rows("a,b,c\n1,2,3\n"), [
|
||||
["a", "b", "c"],
|
||||
["1", "2", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("a trailing newline does not add a blank row", () => {
|
||||
assert.equal(rows("a,b\n1,2\n")!.length, 2);
|
||||
assert.equal(rows("a,b\n1,2")!.length, 2); // and neither does its absence
|
||||
});
|
||||
|
||||
test("a quoted comma stays inside its cell", () => {
|
||||
assert.deepEqual(rows('a,"b,c",d\n')![0], ["a", "b,c", "d"]);
|
||||
});
|
||||
|
||||
test('"" is one literal quote', () => {
|
||||
assert.deepEqual(rows('x,y\n"he said ""hi"" ok",2\n')![1], ['he said "hi" ok', "2"]);
|
||||
});
|
||||
|
||||
test("a newline inside quotes is content, not a row break", () => {
|
||||
const r = rows('a,b\n"two lines\nin one cell",2\n')!;
|
||||
assert.equal(r.length, 2);
|
||||
assert.deepEqual(r[1], ["two lines\nin one cell", "2"]);
|
||||
});
|
||||
|
||||
test("CRLF ends a row like LF", () => {
|
||||
assert.deepEqual(rows("a,b\r\n1,2\r\n"), [
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("a short row keeps its cells — no shifting, no throw", () => {
|
||||
const r = rows("a,b,c\n1,2\n")!;
|
||||
assert.deepEqual(r[1], ["1", "2"]); // the view pads the missing trailing cell
|
||||
});
|
||||
|
||||
test("a long row is not truncated either", () => {
|
||||
assert.deepEqual(rows("a,b\n1,2,3\n")![1], ["1", "2", "3"]);
|
||||
});
|
||||
|
||||
test("empty cells survive, including a trailing one", () => {
|
||||
assert.deepEqual(rows("a,b,c\n1,,\n")![1], ["1", "", ""]);
|
||||
});
|
||||
|
||||
test("rows past the cap are counted, not kept", () => {
|
||||
const text = "a,b\n" + "1,2\n".repeat(10);
|
||||
const out = parseDelimited(text, ",", 4)!;
|
||||
assert.equal(out.rows.length, 4);
|
||||
assert.equal(out.truncated, 7); // 11 rows total, 4 kept
|
||||
});
|
||||
|
||||
test("an unterminated quote falls back to text", () => {
|
||||
assert.equal(parseDelimited('a,b\n"never closed,2\n', ",", CSV_ROWS), null);
|
||||
});
|
||||
|
||||
test("a file with no delimiter is text, not a one-column table", () => {
|
||||
assert.equal(parseDelimited("just some prose\nover two lines\n", ",", CSV_ROWS), null);
|
||||
assert.equal(parseDelimited("", ",", CSV_ROWS), null);
|
||||
});
|
||||
|
||||
test("tabs: the same file with a tab delimiter", () => {
|
||||
assert.deepEqual(rows("a\tb\n1\t2\n", "\t"), [
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
]);
|
||||
// and a comma-delimited file read as TSV is not a table
|
||||
assert.equal(parseDelimited("a,b\n1,2\n", "\t", CSV_ROWS), null);
|
||||
});
|
||||
|
||||
test("a quote that is not at the start of a cell is literal", () => {
|
||||
assert.deepEqual(rows('a,5" pipe,c\n')![0], ["a", '5" pipe', "c"]);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// RFC 4180-ish delimited text, parsed so the viewer can show a table instead
|
||||
// of a wall of monospace. Pure so `npm test` (node's runner) can import it
|
||||
// without React — same reason as lib/sniff.ts.
|
||||
//
|
||||
// The contract that matters: NEVER throw. `null` means "this isn't a table",
|
||||
// and the caller falls back to the plain-text preview. A viewer that
|
||||
// white-screens on a malformed CSV is worse than the wall it replaced.
|
||||
|
||||
export type Csv = {
|
||||
rows: string[][];
|
||||
truncated: number; // rows past the cap — counted, not kept
|
||||
};
|
||||
|
||||
// Rows kept in the DOM. Not a final number; it only has to be stated on
|
||||
// screen. Virtualization is deliberately out of scope — lower this if 5k
|
||||
// rows measures badly.
|
||||
export const CSV_ROWS = 5000;
|
||||
|
||||
export function parseDelimited(text: string, delim: string, cap = CSV_ROWS): Csv | null {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let quoted = false;
|
||||
let truncated = 0;
|
||||
|
||||
const endRow = () => {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
if (rows.length < cap) rows.push(row);
|
||||
else truncated++;
|
||||
row = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quoted) {
|
||||
// Inside quotes a doubled quote is a literal one and a newline is
|
||||
// content, not a row break.
|
||||
if (c !== '"') cell += c;
|
||||
else if (text[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' && cell === "") quoted = true;
|
||||
else if (c === delim) {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
} else if (c === "\n") endRow();
|
||||
else if (c === "\r" && text[i + 1] === "\n") continue; // CRLF
|
||||
else cell += c;
|
||||
}
|
||||
|
||||
if (quoted) return null; // unterminated quote: not something to guess at
|
||||
if (cell !== "" || row.length) endRow(); // last row, no trailing newline
|
||||
if (!rows.length) return null;
|
||||
// A file with no delimiter is not a table, it is text.
|
||||
if (rows[0].length < 2) return null;
|
||||
return { rows, truncated };
|
||||
}
|
||||
@@ -1059,6 +1059,20 @@ input[type="checkbox"] { accent-color: var(--accent); }
|
||||
/* plain file / binary views */
|
||||
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
/* long unbreakable lines wrap instead of blowing out the column: .page has min-width: 0 */
|
||||
/* CSV/TSV table view. Cells never wrap (white-space: pre keeps a quoted
|
||||
newline as a line break without turning a wide file into a tall one), so
|
||||
the box takes the sideways scroll and the page body never does.
|
||||
Every rule is scoped under .csvbox: the file pane carries .markdown, so
|
||||
the plain `.markdown table/th/td` rules would otherwise out-specify a
|
||||
bare .csvview — including the ≤900px one that turns a table into its own
|
||||
scroller, which is exactly the job .csvbox is here to do. */
|
||||
.csvbox { overflow-x: auto; width: fit-content; max-width: 100%; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--code-bg); }
|
||||
.csvbox .csvview { display: table; max-width: none; overflow: visible; margin: 0; border-collapse: collapse; font: 12.5px/1.5 var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.csvbox .csvview th, .csvbox .csvview td { border-bottom: 1px solid var(--border); padding: 8px 14px; text-align: left; white-space: pre; vertical-align: top; color: #c6cbd3; font-size: 12.5px; }
|
||||
.csvbox .csvview th { background: var(--surface); color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; border-bottom-color: var(--border-2); }
|
||||
.csvbox .csvview tr:last-child td { border-bottom: none; }
|
||||
.csvbox .csvview tbody tr:hover td { background: rgba(255,255,255,.02); }
|
||||
.csvnote { color: var(--text-faint); font-size: 12px; margin: 10px 2px 0; }
|
||||
.filecard { margin-top: var(--hero-top); text-align: center; color: var(--text-dim); }
|
||||
.filecard .name { font-size: 1.2em; color: var(--text); margin-bottom: .3em; }
|
||||
.filecard .btn { margin-top: 14px; }
|
||||
|
||||
@@ -2,6 +2,10 @@ export const MD_EXT = /\.(md|markdown)$/i;
|
||||
export const IMG_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|avif)$/i;
|
||||
export const HTML_EXT = /\.html?$/i;
|
||||
export const PDF_EXT = /\.pdf$/i;
|
||||
// Checked BEFORE TEXT_EXT (csv is in both): a parse failure falls back to
|
||||
// the same plain-text view TEXT_EXT would have given. Delimiter comes from
|
||||
// the extension, never from sniffing the bytes.
|
||||
export const CSV_EXT = /\.(csv|tsv)$/i;
|
||||
export const TEXT_EXT =
|
||||
/\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+16
-14
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-ClP6ITud.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C52IQv2y.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cmjeu7KJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7wX--nX5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user