diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 97560d6d9ea..b47d36a50b4 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -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. diff --git a/ui/src/components/CsvPreview.tsx b/ui/src/components/CsvPreview.tsx new file mode 100644 index 00000000000..8a855aed044 --- /dev/null +++ b/ui/src/components/CsvPreview.tsx @@ -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

{parsed.error}

; + if (!parsed.rows.length) return

File is empty.

; + 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 ( +
+
+ CSV preview + {rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"} + {columnCount.toLocaleString()} {columnCount === 1 ? "column" : "columns"} + {(rows.length > MAX_VISIBLE_ROWS || columnCount > MAX_VISIBLE_COLUMNS) && Showing the first {Math.min(rows.length, MAX_VISIBLE_ROWS)} rows and {columns.length} columns. Download for the complete file.} +
+
+ + + + + {columns.map((column) => )} + + + {rows.slice(0, MAX_VISIBLE_ROWS).map((row, index) => + + {columns.map((column) => )} + )} +
{title} — first record used as column headers
Row#{header[column] || `Column ${column + 1}`}
{index + 1}{row[column] ?? ""}
+
+
+ ); +} diff --git a/ui/src/components/FileViewerSheet.test.tsx b/ui/src/components/FileViewerSheet.test.tsx index 8e1ba75d02b..d636129e85e 100644 --- a/ui/src/components/FileViewerSheet.test.tsx +++ b/ui/src/components/FileViewerSheet.test.tsx @@ -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(); + expect(rendered).toContain("); + expect(raw).toContain("export.csv source"); + expect(raw).not.toContain(" 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("rendered"); + const isCsv = resource.previewKind === "text" && content.content.encoding === "utf8" && isCsvFile(resource.displayPath || resource.title, resource.contentType ?? ""); + const [localMode, setMarkdownMode] = useState("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(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 ); - if (!isMarkdown) { + if (!isMarkdown && !isCsv) { return rawSourceView; } return (
-
+ {previewMode === undefined &&
+ } {markdownMode === "raw" ? ( rawSourceView - ) : ( + ) : isCsv ? : (
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 ; @@ -1042,6 +1051,7 @@ export function FileViewerBody({ ); diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx index 00431d9137a..ac86b374f04 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx @@ -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","",12\nJo,"line 1\nline 2",'; + await act(async () => root.render()); + 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('[aria-label="Raw"]')!.click()); + expect(host.querySelector("pre")?.textContent).toBe(text); + expect(host.querySelector("table")).toBeNull(); + await act(async () => host.querySelector('[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()); diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx index 9b2760dea92..a55fd414e50 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx @@ -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 (

{title}

- {markdown ? ( -
+ {markdown || csv ? ( +
@@ -59,8 +63,9 @@ export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
-
+
{text.length === 0 ?

File is empty.

+ : csv && !raw ? : markdown && !raw ? {text} :
{text}
}
@@ -99,5 +104,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string ); } if (content.data === undefined) return

Loading file…

; - return ; + return ; } diff --git a/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx b/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx index 4c776e94bbb..3a9f7c1bb6b 100644 --- a/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx +++ b/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx @@ -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 (
@@ -72,6 +77,10 @@ export function TaskWorkspaceFilePanel({ {copied ? : } ) : null} + {renderedPreview && contentQuery.data ?
+ + +
: null} {downloadUrl ? (