Files
multica/packages/views/editor/utils/parse-structured.ts
6dc6a0b9a2 MUL-7650: attachment viewer — CSV tables, JSON/YAML tree, numbered code, HTML viewports, every kind in a new tab (#8868)
* feat(server): accept JSON Lines in the attachment text preview proxy

The viewer now shows .jsonl / .ndjson files as a tree, so the text proxy's
whitelist takes them too (extension and application/x-ndjson). The whitelist
test gains the CSV / TSV / log / JSON Lines cases and says it is mirrored by
the client-side table in packages/views/editor/utils/preview.test.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): table, tree, numbered-line and viewport views in the attachment viewer

MUL-7650, step 3 of MUL-7642. Fills in how the full-window viewer shows the
file types agents produce most.

- CSV / TSV (`table` kind): parsed to a table on the shared DataTable —
  pinned header, virtualized rows, resizable columns, a frozen row-number
  column. Clicking a header sorts ascending / descending / file order;
  numbers sort numerically, empty cells stay last, all-number columns align
  right. `;`-separated exports are sniffed. DataTable headers now carry
  aria-sort when the table sorts through TanStack's own state.
- JSON / JSON Lines / YAML (`structured` kind): a collapsible tree with
  inline previews of collapsed records and paged long lists; Tree / Raw in
  the top bar. A file that does not parse falls back to the source and says
  why. YAML alias expansion stays capped by the parser.
- Code, text and logs: numbered lines (the number is a pseudo-element, so
  copying skips it) with a sticky gutter, and a wrap toggle. Logs and plain
  text wrap by default, code does not; the reader's choice carries across
  the sequence.
- HTML: Fit / Desktop 1440 / Tablet 768 / Phone 390. A device width lays the
  document out at that width and scales it down when the stage is narrower,
  with the size and scale shown under it; switching never remounts the
  iframe. A source toggle shows the HTML with line numbers.
- Open in new tab works for every previewable kind: /{slug}/attachments/{id}
  /preview loads the record by id and renders the viewer's own top bar and
  stage (AttachmentPreviewStandalone), keeping the desktop scroll restore for
  HTML.
- Whitelist: .jsonl / .ndjson join the text types on both sides, and the
  client test mirrors the server's case table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 04:13:06 +08:00

66 lines
2.0 KiB
TypeScript

/**
* Parses a `structured` attachment (JSON, JSON Lines, YAML) for the viewer's
* tree. A failure carries the parser's own message — it names the line — so
* the viewer can say why it fell back to the source.
*/
import { parseAllDocuments } from "yaml";
import type { StructuredFormat } from "./preview";
export type StructuredParse =
| { ok: true; value: unknown }
| { ok: false; message: string };
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function parseJsonLines(text: string): StructuredParse {
const values: unknown[] = [];
const lines = text.split(/\r\n|\n|\r/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (line.trim() === "") continue;
try {
values.push(JSON.parse(line));
} catch (error) {
return { ok: false, message: `Line ${i + 1}: ${errorMessage(error)}` };
}
}
return { ok: true, value: values };
}
function parseYaml(text: string): StructuredParse {
// Every document in the stream: a multi-document file (`---` separated,
// e.g. Kubernetes manifests) shows as a list of its documents.
const documents = parseAllDocuments(text);
const values: unknown[] = [];
for (const document of documents) {
const [firstError] = document.errors;
if (firstError) return { ok: false, message: firstError.message };
// toJS enforces yaml's alias expansion cap, so a "billion laughs" file
// throws here instead of exhausting memory.
values.push(document.toJS());
}
if (values.length === 0) return { ok: true, value: null };
return { ok: true, value: values.length === 1 ? values[0] : values };
}
export function parseStructured(
text: string,
format: StructuredFormat,
): StructuredParse {
try {
switch (format) {
case "json":
return { ok: true, value: JSON.parse(text) };
case "jsonl":
return parseJsonLines(text);
case "yaml":
return parseYaml(text);
}
} catch (error) {
return { ok: false, message: errorMessage(error) };
}
}