diff --git a/apps/web/app/components/workspace/file-viewer.tsx b/apps/web/app/components/workspace/file-viewer.tsx index eda4eb96056..41724162ca0 100644 --- a/apps/web/app/components/workspace/file-viewer.tsx +++ b/apps/web/app/components/workspace/file-viewer.tsx @@ -1,8 +1,5 @@ "use client"; -import { useState, useEffect } from "react"; -import { read, utils, type WorkBook } from "xlsx"; - const SPREADSHEET_EXTENSIONS = new Set([ "xlsx", "xls", "xlsb", "xlsm", "xltx", "xltm", "ods", "fods", @@ -15,15 +12,9 @@ export function isSpreadsheetFile(filename: string): boolean { return SPREADSHEET_EXTENSIONS.has(ext); } -type FileViewerProps = - | { content: string; filename: string; type: "yaml" | "text" } - | { filename: string; type: "spreadsheet"; url: string; content?: never }; +type FileViewerProps = { content: string; filename: string; type: "yaml" | "text" }; export function FileViewer(props: FileViewerProps) { - if (props.type === "spreadsheet") { - return ; - } - const { content, filename, type } = props; const lines = content.split("\n"); @@ -117,231 +108,6 @@ function FileHeader({ filename, label, icon }: { filename: string; label: string ); } -// --------------------------------------------------------------------------- -// Spreadsheet viewer -// --------------------------------------------------------------------------- - -function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) { - const [workbook, setWorkbook] = useState(null); - const [activeSheet, setActiveSheet] = useState(0); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - setWorkbook(null); - setActiveSheet(0); - setError(null); - - fetch(url) - .then((res) => { - if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);} - return res.arrayBuffer(); - }) - .then((buf) => { - if (cancelled) {return;} - const wb = read(buf, { type: "array" }); - setWorkbook(wb); - }) - .catch((err) => { - if (!cancelled) {setError(String(err));} - }); - - return () => { cancelled = true; }; - }, [url]); - - const ext = filename.split(".").pop()?.toUpperCase() ?? "SPREADSHEET"; - - if (error) { - return ( -
- } /> -
- Failed to load spreadsheet: {error} -
-
- ); - } - - if (!workbook) { - return ( -
- } /> -
- Loading spreadsheet... -
-
- ); - } - - const sheetNames = workbook.SheetNames; - const sheet = workbook.Sheets[sheetNames[activeSheet]]; - const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : []; - - return ( -
- } /> - - {/* Sheet tabs */} - {sheetNames.length > 1 && ( -
- {sheetNames.map((name, idx) => ( - - ))} -
- )} - - {/* Table */} -
- {rows.length === 0 ? ( -
- This sheet is empty. -
- ) : ( - - - - {/* Row number header */} - - ))} - - - - {rows.map((row, rowIdx) => ( - - - {row.map((cell, colIdx) => ( - - ))} - - ))} - -
- {rows[0]?.map((_cell, colIdx) => ( - - {columnLabel(colIdx)} -
- {rowIdx + 1} - - {String(cell)} -
- )} -
- -
- {rows.length} row{rows.length !== 1 ? "s" : ""} - {rows[0] ? ` \u00d7 ${rows[0].length} column${rows[0].length !== 1 ? "s" : ""}` : ""} - {sheetNames.length > 1 ? ` \u00b7 ${sheetNames.length} sheets` : ""} -
-
- ); -} - -/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */ -function columnLabel(idx: number): string { - let label = ""; - let n = idx; - do { - label = String.fromCharCode(65 + (n % 26)) + label; - n = Math.floor(n / 26) - 1; - } while (n >= 0); - return label; -} - -function SpreadsheetIcon() { - return ( - - - - - - - - - ); -} - /** Simple YAML syntax highlighting */ function YamlLine({ line }: { line: string }) { // Comment diff --git a/apps/web/app/components/workspace/spreadsheet-editor.tsx b/apps/web/app/components/workspace/spreadsheet-editor.tsx new file mode 100644 index 00000000000..b04b7afee24 --- /dev/null +++ b/apps/web/app/components/workspace/spreadsheet-editor.tsx @@ -0,0 +1,1163 @@ +"use client"; + +import { + useState, + useEffect, + useCallback, + useRef, + useMemo, + type CSSProperties, +} from "react"; +import Spreadsheet, { + type CellBase, + type Matrix, + type Selection, + type Point, + RangeSelection, + PointRange, + createEmptyMatrix, +} from "react-spreadsheet"; +import { read, utils, write } from "xlsx"; +import { + fileExt, + isTextSpreadsheet, + columnLabel, + cellRef, + sheetToMatrix, + matrixToSheet, + matrixToCsv, + selectionStats, +} from "./spreadsheet-utils"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type SpreadsheetEditorProps = { + url: string; + filename: string; + filePath: string; + compact?: boolean; +}; + +type SheetState = { + data: Matrix; + name: string; +}; + +type UndoEntry = { + sheetIdx: number; + data: Matrix; +}; + +// --------------------------------------------------------------------------- +// Context menu +// --------------------------------------------------------------------------- + +type ContextMenuState = { + x: number; + y: number; + row: number; + col: number; +} | null; + +function ContextMenu({ + state, + onClose, + onInsertRowAbove, + onInsertRowBelow, + onInsertColLeft, + onInsertColRight, + onDeleteRow, + onDeleteCol, + onClearCells, +}: { + state: ContextMenuState; + onClose: () => void; + onInsertRowAbove: () => void; + onInsertRowBelow: () => void; + onInsertColLeft: () => void; + onInsertColRight: () => void; + onDeleteRow: () => void; + onDeleteCol: () => void; + onClearCells: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!state) {return;} + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) {onClose();} + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [state, onClose]); + + if (!state) {return null;} + + const itemStyle: CSSProperties = { + padding: "6px 12px", + fontSize: "12px", + cursor: "pointer", + color: "var(--color-text)", + borderRadius: "4px", + }; + + const items: { label: string; action: () => void; danger?: boolean }[] = [ + { label: "Insert row above", action: onInsertRowAbove }, + { label: "Insert row below", action: onInsertRowBelow }, + { label: "Insert column left", action: onInsertColLeft }, + { label: "Insert column right", action: onInsertColRight }, + { label: "Delete row", action: onDeleteRow, danger: true }, + { label: "Delete column", action: onDeleteCol, danger: true }, + { label: "Clear cells", action: onClearCells, danger: true }, + ]; + + return ( +
+ {items.map((item, i) => ( +
+ {i === 4 && ( +
+ )} + +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Search bar +// --------------------------------------------------------------------------- + +function SearchBar({ + data, + onNavigate, + onClose, +}: { + data: Matrix; + onNavigate: (point: Point) => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [matches, setMatches] = useState([]); + const [currentMatch, setCurrentMatch] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!query.trim()) { + setMatches([]); + setCurrentMatch(0); + return; + } + const q = query.toLowerCase(); + const found: Point[] = []; + data.forEach((row, r) => { + row?.forEach((cell, c) => { + if (cell && String(cell.value).toLowerCase().includes(q)) { + found.push({ row: r, column: c }); + } + }); + }); + setMatches(found); + setCurrentMatch(0); + if (found.length > 0) {onNavigate(found[0]);} + }, [query, data, onNavigate]); + + const goTo = useCallback( + (idx: number) => { + if (matches.length === 0) {return;} + const wrapped = ((idx % matches.length) + matches.length) % matches.length; + setCurrentMatch(wrapped); + onNavigate(matches[wrapped]); + }, + [matches, onNavigate], + ); + + return ( +
+ + + + + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") {onClose();} + if (e.key === "Enter") { + e.preventDefault(); + goTo(e.shiftKey ? currentMatch - 1 : currentMatch + 1); + } + }} + placeholder="Find in sheet..." + style={{ + flex: 1, + background: "transparent", + border: "none", + outline: "none", + fontSize: "12px", + color: "var(--color-text)", + }} + /> + {query && ( + + {matches.length > 0 + ? `${currentMatch + 1} of ${matches.length}` + : "No results"} + + )} + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Toolbar +// --------------------------------------------------------------------------- + +function ToolbarButton({ + onClick, + title, + disabled, + children, + accent, +}: { + onClick: () => void; + title: string; + disabled?: boolean; + children: React.ReactNode; + accent?: boolean; +}) { + return ( + + ); +} + +function ToolbarSep() { + return ( +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function SpreadsheetEditor({ + url, + filename, + filePath, + compact = false, +}: SpreadsheetEditorProps) { + const [sheets, setSheets] = useState(null); + const [activeSheet, setActiveSheet] = useState(0); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveFlash, setSaveFlash] = useState(false); + const [activeCell, setActiveCell] = useState(null); + const [selection, setSelection] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [showSearch, setShowSearch] = useState(false); + const spreadsheetRef = useRef<{ activate: (p: Point) => void } | null>(null); + + // Undo stack (simple: stores full sheet snapshots) + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + + // ------------------------------------------------------------------------- + // Load + // ------------------------------------------------------------------------- + + useEffect(() => { + let cancelled = false; + setSheets(null); + setActiveSheet(0); + setError(null); + setDirty(false); + setUndoStack([]); + setRedoStack([]); + + fetch(url) + .then((res) => { + if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);} + return res.arrayBuffer(); + }) + .then((buf) => { + if (cancelled) {return;} + const wb = read(buf, { type: "array" }); + const loaded: SheetState[] = wb.SheetNames.map((name) => ({ + name, + data: sheetToMatrix(wb.Sheets[name]), + })); + if (loaded.length === 0) { + loaded.push({ + name: "Sheet1", + data: createEmptyMatrix(50, 26), + }); + } + setSheets(loaded); + }) + .catch((err) => { + if (!cancelled) {setError(String(err));} + }); + + return () => { + cancelled = true; + }; + }, [url]); + + // ------------------------------------------------------------------------- + // Current sheet helpers + // ------------------------------------------------------------------------- + + const currentData = sheets?.[activeSheet]?.data ?? []; + + const colCount = useMemo(() => { + let max = 0; + for (const row of currentData) { + if (row) {max = Math.max(max, row.length);} + } + return max; + }, [currentData]); + + const columnLabels = useMemo( + () => Array.from({ length: colCount }, (_, i) => columnLabel(i)), + [colCount], + ); + + // ------------------------------------------------------------------------- + // Mutations + // ------------------------------------------------------------------------- + + const pushUndo = useCallback(() => { + if (!sheets) {return;} + setUndoStack((prev) => [ + ...prev.slice(-49), + { sheetIdx: activeSheet, data: sheets[activeSheet].data }, + ]); + setRedoStack([]); + }, [sheets, activeSheet]); + + const updateCurrentSheet = useCallback( + (newData: Matrix) => { + setSheets((prev) => { + if (!prev) {return prev;} + const next = [...prev]; + next[activeSheet] = { ...next[activeSheet], data: newData }; + return next; + }); + setDirty(true); + }, + [activeSheet], + ); + + const handleChange = useCallback( + (newData: Matrix) => { + pushUndo(); + updateCurrentSheet(newData); + }, + [pushUndo, updateCurrentSheet], + ); + + const undo = useCallback(() => { + if (undoStack.length === 0 || !sheets) {return;} + const entry = undoStack[undoStack.length - 1]; + setRedoStack((prev) => [ + ...prev, + { sheetIdx: activeSheet, data: sheets[activeSheet].data }, + ]); + setUndoStack((prev) => prev.slice(0, -1)); + setSheets((prev) => { + if (!prev) {return prev;} + const next = [...prev]; + next[entry.sheetIdx] = { ...next[entry.sheetIdx], data: entry.data }; + return next; + }); + if (entry.sheetIdx !== activeSheet) {setActiveSheet(entry.sheetIdx);} + }, [undoStack, sheets, activeSheet]); + + const redo = useCallback(() => { + if (redoStack.length === 0 || !sheets) {return;} + const entry = redoStack[redoStack.length - 1]; + setUndoStack((prev) => [ + ...prev, + { sheetIdx: activeSheet, data: sheets[activeSheet].data }, + ]); + setRedoStack((prev) => prev.slice(0, -1)); + setSheets((prev) => { + if (!prev) {return prev;} + const next = [...prev]; + next[entry.sheetIdx] = { ...next[entry.sheetIdx], data: entry.data }; + return next; + }); + if (entry.sheetIdx !== activeSheet) {setActiveSheet(entry.sheetIdx);} + }, [redoStack, sheets, activeSheet]); + + // Row/col operations + const insertRow = useCallback( + (at: number) => { + pushUndo(); + const newRow = Array.from({ length: colCount }, () => ({ value: "" }) as CellBase); + const next = [...currentData]; + next.splice(at, 0, newRow); + updateCurrentSheet(next); + }, + [pushUndo, updateCurrentSheet, currentData, colCount], + ); + + const deleteRow = useCallback( + (at: number) => { + if (currentData.length <= 1) {return;} + pushUndo(); + const next = [...currentData]; + next.splice(at, 1); + updateCurrentSheet(next); + }, + [pushUndo, updateCurrentSheet, currentData], + ); + + const insertCol = useCallback( + (at: number) => { + pushUndo(); + const next = currentData.map((row) => { + const r = [...(row ?? [])]; + r.splice(at, 0, { value: "" }); + return r; + }); + updateCurrentSheet(next); + }, + [pushUndo, updateCurrentSheet, currentData], + ); + + const deleteCol = useCallback( + (at: number) => { + if (colCount <= 1) {return;} + pushUndo(); + const next = currentData.map((row) => { + const r = [...(row ?? [])]; + r.splice(at, 1); + return r; + }); + updateCurrentSheet(next); + }, + [pushUndo, updateCurrentSheet, currentData, colCount], + ); + + const clearSelection = useCallback(() => { + if (!selection) {return;} + const range = selection.toRange(currentData); + if (!range) {return;} + pushUndo(); + const next = currentData.map((row) => (row ? [...row] : [])); + for (const pt of range) { + if (next[pt.row]) { + next[pt.row][pt.column] = { value: "" }; + } + } + updateCurrentSheet(next); + }, [selection, currentData, pushUndo, updateCurrentSheet]); + + // ------------------------------------------------------------------------- + // Save + // ------------------------------------------------------------------------- + + const save = useCallback(async () => { + if (!sheets || saving) {return;} + setSaving(true); + try { + if (isTextSpreadsheet(filename)) { + const sep = fileExt(filename) === "tsv" ? "\t" : ","; + const text = matrixToCsv(sheets[activeSheet].data, sep); + const res = await fetch("/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: filePath, content: text }), + }); + if (!res.ok) {throw new Error("Save failed");} + } else { + const wb = utils.book_new(); + for (const s of sheets) { + utils.book_append_sheet(wb, matrixToSheet(s.data), s.name); + } + const buf = write(wb, { type: "array", bookType: fileExt(filename) as "xlsx" }); + const res = await fetch( + `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}`, + { method: "POST", body: buf }, + ); + if (!res.ok) {throw new Error("Save failed");} + } + setDirty(false); + setSaveFlash(true); + setTimeout(() => setSaveFlash(false), 1500); + } catch (err) { + console.error("Spreadsheet save error:", err); + } finally { + setSaving(false); + } + }, [sheets, saving, filename, filePath, activeSheet]); + + // ------------------------------------------------------------------------- + // Keyboard shortcuts + // ------------------------------------------------------------------------- + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key === "s") { + e.preventDefault(); + void save(); + } + if (mod && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + } + if (mod && e.key === "z" && e.shiftKey) { + e.preventDefault(); + redo(); + } + if (mod && e.key === "f") { + e.preventDefault(); + setShowSearch((p) => !p); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [save, undo, redo]); + + // Context menu on spreadsheet area + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const target = e.target as HTMLElement; + const td = target.closest("td"); + if (!td) {return;} + const tr = td.closest("tr"); + if (!tr) {return;} + const tbody = tr.closest("tbody"); + if (!tbody) {return;} + const rowIdx = Array.from(tbody.children).indexOf(tr); + const colIdx = Array.from(tr.children).indexOf(td) - 1; // subtract row indicator + if (rowIdx < 0 || colIdx < 0) {return;} + setContextMenu({ x: e.clientX, y: e.clientY, row: rowIdx, col: colIdx }); + }, + [], + ); + + // Navigate to cell (for search) + const navigateToCell = useCallback((point: Point) => { + setActiveCell(point); + spreadsheetRef.current?.activate(point); + setSelection(new RangeSelection(new PointRange(point, point))); + }, []); + + // ------------------------------------------------------------------------- + // Formula bar value + // ------------------------------------------------------------------------- + + const activeCellValue = useMemo(() => { + if (!activeCell) {return "";} + const cell = currentData[activeCell.row]?.[activeCell.column]; + return cell ? String(cell.value) : ""; + }, [activeCell, currentData]); + + const handleFormulaChange = useCallback( + (value: string) => { + if (!activeCell) {return;} + pushUndo(); + const next = currentData.map((row) => (row ? [...row] : [])); + if (!next[activeCell.row]) {next[activeCell.row] = [];} + next[activeCell.row][activeCell.column] = { value }; + updateCurrentSheet(next); + }, + [activeCell, currentData, pushUndo, updateCurrentSheet], + ); + + // ------------------------------------------------------------------------- + // Stats + // ------------------------------------------------------------------------- + + const stats = useMemo( + () => selectionStats(currentData, selection), + [currentData, selection], + ); + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + const ext = fileExt(filename).toUpperCase() || "SPREADSHEET"; + + if (error) { + return ( +
+ +

+ Failed to load spreadsheet +

+

{error}

+
+ ); + } + + if (!sheets) { + return ( +
+
+

+ Loading spreadsheet... +

+
+ ); + } + + return ( +
+ {/* -- Header / Toolbar -------------------------------------------- */} +
+ + + {filename} + + + {ext} + + {dirty && ( + + Unsaved + + )} + {saveFlash && ( + + Saved + + )} + +
+ + {/* Toolbar buttons */} + {!compact && ( +
+ + + + + + + + + + + + + + + + + + + + activeCell && insertRow(activeCell.row)} + title="Insert row above" + disabled={!activeCell} + > + + + + + activeCell && insertCol(activeCell.column)} + title="Insert column left" + disabled={!activeCell} + > + + + + + + + setShowSearch((p) => !p)} title="Find (⌘F)"> + + + + +
+ )} + + {/* Compact save button */} + {compact && dirty && ( + + + + + + + + )} +
+ + {/* -- Formula bar ------------------------------------------------- */} + {!compact && ( +
+ + {activeCell ? cellRef(activeCell) : "\u00A0"} + +
+ + fx + + handleFormulaChange(e.target.value)} + placeholder="Select a cell" + style={{ + flex: 1, + background: "transparent", + border: "none", + outline: "none", + fontSize: "12px", + color: "var(--color-text)", + fontFamily: "monospace", + }} + /> +
+ )} + + {/* -- Search bar -------------------------------------------------- */} + {showSearch && ( + setShowSearch(false)} + /> + )} + + {/* -- Spreadsheet grid -------------------------------------------- */} +
+ } + data={currentData} + onChange={handleChange} + columnLabels={columnLabels} + onActivate={setActiveCell} + onSelect={setSelection} + darkMode={typeof document !== "undefined" && document.documentElement.classList.contains("dark")} + className="spreadsheet-editor-grid" + /> +
+ + {/* -- Context menu ------------------------------------------------ */} + setContextMenu(null)} + onInsertRowAbove={() => contextMenu && insertRow(contextMenu.row)} + onInsertRowBelow={() => contextMenu && insertRow(contextMenu.row + 1)} + onInsertColLeft={() => contextMenu && insertCol(contextMenu.col)} + onInsertColRight={() => contextMenu && insertCol(contextMenu.col + 1)} + onDeleteRow={() => contextMenu && deleteRow(contextMenu.row)} + onDeleteCol={() => contextMenu && deleteCol(contextMenu.col)} + onClearCells={clearSelection} + /> + + {/* -- Sheet tabs -------------------------------------------------- */} + {sheets.length > 1 && ( +
+ {sheets.map((s, idx) => ( + + ))} +
+ )} + + {/* -- Status bar -------------------------------------------------- */} +
+ + {currentData.length} row{currentData.length !== 1 ? "s" : ""} + {" \u00d7 "} + {colCount} col{colCount !== 1 ? "s" : ""} + {sheets.length > 1 + ? ` \u00b7 ${sheets.length} sheets` + : ""} + + {stats && ( + + Count: {stats.count} + {stats.numericCount > 0 && ( + <> + {" \u00b7 "}Sum: {stats.sum.toLocaleString()} + {" \u00b7 "}Avg: {stats.avg.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + )} + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Icon +// --------------------------------------------------------------------------- + +function SpreadsheetIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + + + ); +}