feat(ui): render CSV files in task tabs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadottaandPaperclip committed 2026-10-07 12:51:56 +00:00
1 parent b315580640
commit b7da00cc00
10 files changed
+202 -20

No files matched your search

+9
View File
@@ -232,3 +232,12 @@ to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread,
Blocked, Recent, Everything) and the task-status views (All, Active, Backlog,
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
carry a task filter open All. Old `/inbox` links redirect to the matching view.
### CSV file previews
Task attachment and workspace file tabs render CSV files as tables by default.
The first record supplies column headers. Row numbers, row and column counts,
and sticky headers help operators scan exports. Rendered and raw view icons
remain next to download; raw view preserves the original source. The table
shows up to 500 data rows and 100 columns, with a notice when the preview is
limited. Download retains the complete file.
+41
View File
@@ -0,0 +1,41 @@
import { useMemo } from "react";
import { parseCsv } from "@/lib/csv-preview";
const MAX_VISIBLE_ROWS = 500;
const MAX_VISIBLE_COLUMNS = 100;
export function CsvPreview({ text, title }: { text: string; title: string }) {
const parsed = useMemo(() => {
try { return { rows: parseCsv(text), error: null }; }
catch (error) { return { rows: [], error: (error as Error).message }; }
}, [text]);
if (parsed.error) return <p role="alert" className="p-4 text-sm text-muted-foreground">{parsed.error}</p>;
if (!parsed.rows.length) return <p className="p-4 text-sm text-muted-foreground">File is empty.</p>;
const [header, ...rows] = parsed.rows;
const columnCount = parsed.rows.reduce((count, row) => Math.max(count, row.length), 0);
const columns = Array.from({ length: Math.min(columnCount, MAX_VISIBLE_COLUMNS) }, (_, index) => index);
return (
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground" role="status">
<span className="font-medium text-foreground">CSV preview</span>
<span>{rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"}</span>
<span>{columnCount.toLocaleString()} {columnCount === 1 ? "column" : "columns"}</span>
{(rows.length > MAX_VISIBLE_ROWS || columnCount > MAX_VISIBLE_COLUMNS) && <span>Showing the first {Math.min(rows.length, MAX_VISIBLE_ROWS)} rows and {columns.length} columns. Download for the complete file.</span>}
</div>
<div className="min-h-0 flex-1 overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" role="region" aria-label={`${title} CSV table`} tabIndex={0}>
<table className="w-full border-collapse text-sm">
<caption className="sr-only">{title} — first record used as column headers</caption>
<thead className="sticky top-0 z-10 bg-muted">
<tr><th scope="col" className="w-10 border-b border-border px-3 py-3 text-right font-mono text-xs text-muted-foreground"><span className="sr-only">Row</span>#</th>
{columns.map((column) => <th key={column} scope="col" className="whitespace-nowrap border-b border-border px-4 py-3 text-left font-medium">{header[column] || `Column ${column + 1}`}</th>)}
</tr>
</thead>
<tbody>{rows.slice(0, MAX_VISIBLE_ROWS).map((row, index) => <tr key={index} className="even:bg-muted/30 hover:bg-accent/50">
<th scope="row" className="border-b border-border/50 px-3 py-2 text-right font-mono text-xs font-normal text-muted-foreground">{index + 1}</th>
{columns.map((column) => <td key={column} className="whitespace-pre-wrap border-b border-border/50 px-4 py-2 align-top">{row[column] ?? ""}</td>)}
</tr>)}</tbody>
</table>
</div>
</div>
);
}
@@ -168,3 +168,20 @@ describe("FileViewerMetadataRow", () => {
expect(markup).toContain("Loading file details");
});
});
describe("workspace CSV content", () => {
const csv: WorkspaceFileContent = {
resource: { kind: "file", provider: "git_worktree", title: "export.csv", displayPath: "export.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: 20, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } },
content: { encoding: "utf8", data: 'Name,Amount\n"Lee, Sam",12' },
};
it("defaults to a table and supports toolbar-controlled raw mode", () => {
const rendered = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="rendered" />);
expect(rendered).toContain("<table");
expect(rendered).toContain("Lee, Sam");
expect(rendered).not.toContain("CSV preview mode");
const raw = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="raw" />);
expect(raw).toContain("export.csv source");
expect(raw).not.toContain("<table");
});
});
+22 -12
View File
@@ -1,3 +1,5 @@
import { CsvPreview } from "./CsvPreview";
import { isCsvFile } from "@/lib/csv-preview";
import {
useCallback,
useEffect,
@@ -234,14 +236,18 @@ interface FileContentViewerProps {
content: WorkspaceFileContent;
highlightedLine: number | null;
onLoaded?: (summary: string) => void;
previewMode?: "raw" | "rendered";
}
type MarkdownPreviewMode = "raw" | "rendered";
export function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
export function FileContentViewer({ content, highlightedLine, onLoaded, previewMode }: FileContentViewerProps) {
const { resource } = content;
const isMarkdown = resource.previewKind === "text" && content.content.encoding === "utf8" && isMarkdownResource(resource);
const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>("rendered");
const isCsv = resource.previewKind === "text" && content.content.encoding === "utf8" && isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "");
const [localMode, setMarkdownMode] = useState<MarkdownPreviewMode>("rendered");
const markdownMode = previewMode ?? localMode;
const previewLabel = isCsv ? "CSV" : "Markdown";
const lines = useMemo(() => {
if (resource.previewKind === "text") {
return splitContentIntoLines(content.content.data);
@@ -253,8 +259,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
const highlightedLineRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMarkdownMode(isMarkdown ? "rendered" : "raw");
}, [isMarkdown, resource.displayPath, resource.title, resource.contentType]);
setMarkdownMode(isMarkdown || isCsv ? "rendered" : "raw");
}, [isMarkdown, isCsv, resource.displayPath, resource.title, resource.contentType]);
useEffect(() => {
if (!lines) return;
@@ -373,24 +379,24 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
</div>
);
if (!isMarkdown) {
if (!isMarkdown && !isCsv) {
return rawSourceView;
}
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="absolute right-3 top-3 z-20">
{previewMode === undefined && <div className="absolute right-3 top-3 z-20">
<div
role="group"
aria-label="Markdown preview mode"
aria-label={`${previewLabel} preview mode`}
className="inline-flex rounded-md border border-border bg-background/95 p-0.5 shadow-sm backdrop-blur"
>
<Button
type="button"
variant={markdownMode === "rendered" ? "secondary" : "ghost"}
size="icon-sm"
aria-label="Show rendered Markdown"
title="Rendered Markdown"
aria-label={`Show rendered ${previewLabel}`}
title={`Rendered ${previewLabel}`}
aria-pressed={markdownMode === "rendered"}
onClick={() => setMarkdownMode("rendered")}
className={cn(
@@ -404,8 +410,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
type="button"
variant={markdownMode === "raw" ? "secondary" : "ghost"}
size="icon-sm"
aria-label="Show raw Markdown"
title="Raw Markdown"
aria-label={`Show raw ${previewLabel}`}
title={`Raw ${previewLabel}`}
aria-pressed={markdownMode === "raw"}
onClick={() => setMarkdownMode("raw")}
className={cn(
@@ -417,9 +423,10 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
</Button>
</div>
</div>
}
{markdownMode === "raw" ? (
rawSourceView
) : (
) : isCsv ? <CsvPreview text={content.content.data} title={resource.title} /> : (
<div
role="region"
aria-label={`${resource.title} rendered Markdown`}
@@ -934,6 +941,7 @@ interface FileViewerBodyProps {
onRetry: () => void;
onSetAnnouncement: (message: string) => void;
onFallbackToProject: null | (() => void);
previewMode?: "raw" | "rendered";
}
export function FileViewerBody({
@@ -945,6 +953,7 @@ export function FileViewerBody({
onRetry,
onSetAnnouncement,
onFallbackToProject,
previewMode,
}: FileViewerBodyProps) {
if (resolveQuery.isFetching && !resolveQuery.data) {
return <LoadingView elapsedMs={elapsedMs} />;
@@ -1042,6 +1051,7 @@ export function FileViewerBody({
<FileContentViewer
content={contentQuery.data}
highlightedLine={highlightedLine}
previewMode={previewMode}
onLoaded={onSetAnnouncement}
/>
);
@@ -54,6 +54,22 @@ describe("text attachment tabs", () => {
expect(host.querySelector("[data-rendered]")).not.toBeNull();
await act(async () => root.unmount()); host.remove();
});
it("renders CSV cells safely and switches to the exact raw source beside download", async () => {
const host = document.createElement("div"); const root = createRoot(host);
const text = 'Name,Notes,Amount\n"Lee, Sam","<script>hello</script>",12\nJo,"line 1\nline 2",';
await act(async () => root.render(<TextAttachmentPreview title="export.csv" markdown={false} csv text={text} downloadUrl="/download" />));
expect(host.querySelector("table")).not.toBeNull();
expect(host.textContent).toContain("2 rows");
expect(host.querySelector("td")?.textContent).toBe("Lee, Sam");
expect(host.querySelector("script")).toBeNull();
expect(host.querySelector("header")?.lastElementChild?.getAttribute("href")).toBe("/download");
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
expect(host.querySelector("pre")?.textContent).toBe(text);
expect(host.querySelector("table")).toBeNull();
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Rendered"]')!.click());
expect(host.querySelector("table")).not.toBeNull();
await act(async () => root.unmount());
});
it("shows plain text literally without Markdown controls", async () => {
const host = document.createElement("div"); const root = createRoot(host);
await act(async () => root.render(<TextAttachmentPreview title="notes.txt" markdown={false} text="<script>alert(1)</script>" downloadUrl="/download" />));
@@ -1,8 +1,10 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Code2, Download, Eye } from "lucide-react";
import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button";
import { CsvPreview } from "@/components/CsvPreview";
import { isCsvFile } from "@/lib/csv-preview";
import { MarkdownBody } from "@/components/MarkdownBody";
import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments";
import { queryKeys } from "@/lib/queryKeys";
@@ -34,19 +36,21 @@ export async function readTextPreview(response: Response) {
}
}
export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
export function TextAttachmentPreview({ title, text, markdown, downloadUrl, csv = false }: {
title: string;
text: string;
markdown: boolean;
csv?: boolean;
downloadUrl: string;
}) {
const [raw, setRaw] = useState(false);
useEffect(() => setRaw(false), [title]);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<h2 className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>{title}</h2>
{markdown ? (
<div className="flex gap-1" role="group" aria-label="Markdown view">
{markdown || csv ? (
<div className="flex gap-1" role="group" aria-label={csv ? "CSV view" : "Markdown view"}>
<Button size="icon-sm" variant={raw ? "ghost" : "secondary"} aria-label="Rendered" title="Rendered" aria-pressed={!raw} onClick={() => setRaw(false)}>
<Eye aria-hidden />
</Button>
@@ -59,8 +63,9 @@ export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
<a href={downloadUrl} download aria-label={`Download ${title}`} title={`Download ${title}`}><Download aria-hidden /></a>
</Button>
</header>
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className={csv && !raw ? "flex min-h-0 flex-1 flex-col overflow-hidden" : "min-h-0 flex-1 overflow-auto p-4"}>
{text.length === 0 ? <p className="text-sm text-muted-foreground">File is empty.</p>
: csv && !raw ? <CsvPreview text={text} title={title} />
: markdown && !raw ? <MarkdownBody mediaMode="reference">{text}</MarkdownBody>
: <pre className="whitespace-pre-wrap break-words font-mono text-sm" aria-label={`${title} raw text`}>{text}</pre>}
</div>
@@ -99,5 +104,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string
);
}
if (content.data === undefined) return <p className="p-4 text-sm" role="status">Loading file…</p>;
return <TextAttachmentPreview title={attachment.originalFilename ?? attachment.id} text={content.data} markdown={isMarkdownAttachment(attachment)} downloadUrl={downloadUrl} />;
return <TextAttachmentPreview title={attachment.originalFilename ?? attachment.id} text={content.data} csv={isCsvFile(attachment.originalFilename ?? "", attachment.contentType)} markdown={isMarkdownAttachment(attachment)} downloadUrl={downloadUrl} />;
}
@@ -1,6 +1,7 @@
import { useState } from "react";
import { isCsvFile } from "@/lib/csv-preview";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, Copy, Download } from "lucide-react";
import { Check, Copy, Download, Eye, Code2 } from "lucide-react";
import { fileResourcesApi } from "@/api/file-resources";
import { FileViewerBody, FileViewerMetadataRow } from "@/components/FileViewerSheet";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,8 @@ export function TaskWorkspaceFilePanel({
payload: WorkspaceFilePayload;
onFallbackToProject?: () => void;
}) {
const [previewMode, setPreviewMode] = useState<"raw" | "rendered">("rendered");
useEffect(() => setPreviewMode("rendered"), [payload.path]);
const [copied, setCopied] = useState(false);
const [announcement, setAnnouncement] = useState("");
const state: FileViewerUrlState = {
@@ -46,6 +49,8 @@ export function TaskWorkspaceFilePanel({
? fileResourcesApi.downloadUrl(issueId, state)
: null;
const renderedPreview = resource && (isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "") || /\.(md|markdown|mdown|mkdn|mkd)$/i.test(resource.displayPath || resource.title) || resource.contentType?.includes("markdown"));
return (
<div className="flex h-full min-h-0 flex-col bg-muted/20">
<header className="flex min-w-0 items-start gap-2 border-b border-border bg-card px-3 py-2">
@@ -72,6 +77,10 @@ export function TaskWorkspaceFilePanel({
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
</Button>
) : null}
{renderedPreview && contentQuery.data ? <div role="group" aria-label="File preview mode" className="flex gap-1">
<Button size="icon-sm" variant={previewMode === "rendered" ? "secondary" : "ghost"} aria-label="Rendered" title="Rendered" aria-pressed={previewMode === "rendered"} onClick={() => setPreviewMode("rendered")}><Eye aria-hidden /></Button>
<Button size="icon-sm" variant={previewMode === "raw" ? "secondary" : "ghost"} aria-label="Raw" title="Raw" aria-pressed={previewMode === "raw"} onClick={() => setPreviewMode("raw")}><Code2 aria-hidden /></Button>
</div> : null}
{downloadUrl ? (
<Button asChild variant="ghost" size="icon-sm">
<a href={downloadUrl} download={resource?.title} aria-label="Download file" title="Download file">
@@ -84,6 +93,7 @@ export function TaskWorkspaceFilePanel({
<div className="min-h-0 flex-1 overflow-hidden">
<div className="sr-only" aria-live="polite">{announcement}</div>
<FileViewerBody
previewMode={previewMode}
resolveQuery={resourceQuery}
contentQuery={contentQuery}
elapsedMs={0}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { isCsvFile, parseCsv } from "./csv-preview";
describe("CSV preview parsing", () => {
it("handles BOM, CRLF, quoted commas, escaped quotes and multiline cells", () => {
expect(parseCsv('\uFEFFName,Notes,Amount\r\n"Lee, Sam","Said ""hello""\nagain",12\r\n')).toEqual([
["Name", "Notes", "Amount"], ["Lee, Sam", 'Said "hello"\nagain', "12"],
]);
});
it("preserves trailing empty fields, blank records and uneven rows", () => {
expect(parseCsv("a,b\n1,\n\n2,3,4")).toEqual([["a", "b"], ["1", ""], [""], ["2", "3", "4"]]);
expect(parseCsv("")).toEqual([]);
expect(parseCsv("a,b")).toEqual([["a", "b"]]);
});
it("reports incomplete quoted fields", () => {
expect(() => parseCsv('a,b\n"unfinished')).toThrow("incomplete");
});
it("recognizes CSV filenames and media types", () => {
expect(isCsvFile("EXPORT.CSV", "application/octet-stream")).toBe(true);
expect(isCsvFile("download", "text/csv; charset=utf-8")).toBe(true);
expect(isCsvFile("notes.txt", "text/plain")).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
export function isCsvFile(filename: string, contentType = "") {
return /\.csv$/i.test(filename) || contentType.split(";")[0].trim().toLowerCase() === "text/csv";
}
/** Parse comma-separated records, preserving quoted newlines and escaped quotes. */
export function parseCsv(text: string): string[][] {
const source = text.replace(/^\uFEFF/, "");
if (!source) return [];
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let i = 0; i < source.length; i++) {
const char = source[i];
if (char === '"') {
if (quoted && source[i + 1] === '"') { field += '"'; i++; }
else if (quoted || field === "") quoted = !quoted;
else field += char;
} else if (!quoted && char === ",") {
row.push(field); field = "";
} else if (!quoted && (char === "\n" || char === "\r")) {
row.push(field); rows.push(row); row = []; field = "";
if (char === "\r" && source[i + 1] === "\n") i++;
} else field += char;
}
if (quoted) throw new Error("A quoted field is incomplete. Switch to raw view to inspect the file.");
if (field !== "" || row.length || !/[\r\n]$/.test(source)) { row.push(field); rows.push(row); }
return rows;
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel";
const meta: Meta<typeof TextAttachmentPreview> = {
title: "Components/CSV File Tab",
component: TextAttachmentPreview,
parameters: { layout: "fullscreen" },
decorators: [(Story) => <div className="h-screen bg-background text-foreground"><Story /></div>],
};
export default meta;
type Story = StoryObj<typeof TextAttachmentPreview>;
export const RevenueExport: Story = {
args: {
title: "monthly-revenue.csv",
markdown: false,
csv: true,
downloadUrl: "data:text/csv,Month%2CRevenue%0AOctober%2C12800",
text: 'Month,Customer,Plan,Revenue,Status,Notes\nOctober,"Acme, Inc.",Enterprise,12800,Active,"Expanded to 40 seats"\nOctober,Northstar Studio,Pro,2400,Active,"Annual renewal"\nOctober,Atlas Labs,Enterprise,9600,Active,"Includes onboarding"\nOctober,Juniper Design,Starter,480,Trial,"Review on Friday"\nSeptember,"Acme, Inc.",Enterprise,11200,Active,"32 seats"\nSeptember,Northstar Studio,Pro,2400,Active,"Annual renewal"\nSeptember,Atlas Labs,Pro,4800,Active,"Upgrade scheduled"\nSeptember,Juniper Design,Starter,480,Trial,"First month"',
},
};
export const Empty: Story = { args: { ...RevenueExport.args, text: "" } };
export const Invalid: Story = { args: { ...RevenueExport.args, text: 'Name,Notes\nSam,"unfinished' } };