From 661e052d79211beff70d04b4ecc310a7359b1d64 Mon Sep 17 00:00:00 2001 From: Obvious Date: Tue, 20 Oct 2026 03:12:52 +0000 Subject: [PATCH 1/3] feat(table): add column visibility picker and opt-in Apply view to output projection Add projectCsvView to serialize active filtered/sorted rows and checked columns, a Columns visibility popover in CsvTable with source-column mapping across sort/edit/paste/delete/selection, and an opt-in Apply view to output toggle wired through InputPane and OutputPane into App. Co-authored-by: David Boskovic --- app/src/App.tsx | 32 +++- app/src/components/CsvTable.tsx | 274 +++++++++++++++++++++++++++--- app/src/components/InputPane.tsx | 3 + app/src/components/OutputPane.tsx | 4 + app/src/lib/tableView.ts | 36 ++++ 5 files changed, 314 insertions(+), 35 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index e98a7d5..f4c0baa 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -127,6 +127,8 @@ export default function App() { // localStorage for one-click restore from the TopBar Recent popover. const [recentSnapshots, setRecentSnapshots] = useState(loadRecentHistory); + const [projectedInputCsv, setProjectedInputCsv] = useState(null); + const [projectedOutputCsv, setProjectedOutputCsv] = useState(null); // Latest converter state for the conversion-event validity predicate — // fires happen up to 2s after the last edit (or synchronously for a @@ -204,6 +206,10 @@ export default function App() { const isDiscreteUploadInput = input !== "" && uploadedRef.current?.text === input; const effectiveInput = isDiscreteUploadInput ? input : debouncedInput; + const conversionInput = + csvToJson && projectedInputCsv !== null && effectiveInput.trim() !== "" + ? projectedInputCsv + : effectiveInput; // Legacy permalinks: //<32-hex-id> hydrates the converter from the // S3 object, read-only. The URL is never rewritten; edits behave normally @@ -221,8 +227,8 @@ export default function App() { const permalink = useLegacyPermalink(permalinkPath); const result = useMemo( - () => convertText(direction, effectiveInput, options), - [direction, effectiveInput, options] + () => convertText(direction, conversionInput, options), + [direction, conversionInput, options] ); // Fire-time validity reads this; assigned after the memo so it always // holds the freshest state (see the tracker's isValid above). @@ -322,11 +328,15 @@ export default function App() { // re-running the conversion from the CURRENT input + options — not the // debounced snapshot, which can lag a fresh keystroke by up to 1s. const regenerateOutput = useCallback(() => { - const current = convertText(direction, input, options); + const sourceText = + csvToJson && projectedInputCsv !== null && input.trim() !== "" + ? projectedInputCsv + : input; + const current = convertText(direction, sourceText, options); if (current.ok) setLastValidOutput(current.text); setOutputEdited(false); setEditedOutput(null); - }, [direction, input, options]); + }, [csvToJson, direction, input, options, projectedInputCsv]); const commitInput = useCallback( (value: string) => { @@ -616,6 +626,10 @@ export default function App() { }); void copyText(input); }; + const effectiveOutputExportText = + !csvToJson && projectedOutputCsv !== null + ? projectedOutputCsv + : displayedOutput; const copyOutput = () => { trackExport({ via: "copy", @@ -623,9 +637,9 @@ export default function App() { pane: "output", direction: analyticsDirection, }); - // Copy always delivers the pane's current text — the active egress format - // or the edited JSON when the edited state holds. - if (displayedOutput) void copyText(displayedOutput); + // Copy always delivers the pane's current text — the active egress format, + // projected CSV view, or the edited JSON when the edited state holds. + if (effectiveOutputExportText) void copyText(effectiveOutputExportText); }; const downloadInput = () => { trackExport({ @@ -650,7 +664,7 @@ export default function App() { direction: analyticsDirection, }); downloadText( - displayedOutput ?? "", + effectiveOutputExportText ?? "", outputFilenameFor( filename, csvToJson ? "csv2json" : "json2csv", @@ -729,6 +743,7 @@ export default function App() { }} onCopy={copyInput} onCopySelection={trackInputSelectionCopy} + onProjectedCsvChange={setProjectedInputCsv} onDownload={downloadInput} canUndo={canUndo} canRedo={canRedo} @@ -783,6 +798,7 @@ export default function App() { dark={theme === "dark"} onCopy={copyOutput} onCopySelection={trackOutputSelectionCopy} + onProjectedCsvChange={setProjectedOutputCsv} onNativeCopy={trackOutputNativeCopy} onDownload={downloadOutput} onEgressFormatChange={setEgressFormat} diff --git a/app/src/components/CsvTable.tsx b/app/src/components/CsvTable.tsx index e7cbad3..ffa6fa4 100644 --- a/app/src/components/CsvTable.tsx +++ b/app/src/components/CsvTable.tsx @@ -17,19 +17,20 @@ import { ImageWindowLoaderImpl, } from "@glideapps/glide-data-grid"; import "@glideapps/glide-data-grid/dist/index.css"; -import { Copy, Search, Trash2, X } from "lucide-react"; +import { Columns3, Copy, Search, Trash2, X } from "lucide-react"; import { type ColumnTypeOverride } from "@/lib/convert"; -import { numericColumns, parseCsvRecord, parseCsvTable, serializeCsvRow } from "@/lib/csvTable"; +import { numericColumns, parseCsvRecord, parseCsvTable, serializeCsvRow, type CsvTableData } from "@/lib/csvTable"; import { detectMarkdownOrCliTable } from "@/lib/markdownTable"; import { deleteColumns, deleteRows, renameHeader } from "@/lib/tableDeletion"; import { matchHistoryShortcut } from "@/lib/csvHistory"; -import { filterRowIndices, sortRowIndices, type SortDir } from "@/lib/tableView"; +import { filterRowIndices, projectCsvView, sortRowIndices, type SortDir } from "@/lib/tableView"; import { computeSelectionStats, serializeGridSelection, type SelectionShape, type SelectionStats, } from "@/lib/tableSelection"; +import { cn } from "@/lib/utils"; /** * Dense full-bleed CSV/TSV table (spec: CSV pane — sticky header, compact @@ -96,6 +97,13 @@ type CsvTableProps = { columnTypes?: Record; /** Updates per-column type overrides (input table only). */ onChangeColumnTypes?: (columnTypes: Record) => void; + /** + * Emits the projected CSV string when "Apply view to output" is active and + * at least one view transform (search filter, sort, or column subset) is + * active; emits `null` when toggled off or when all rows/columns are in + * natural source order. + */ + onProjectedCsvChange?: (projectedCsv: string | null) => void; }; const COLUMN_TYPE_OPTIONS: readonly { @@ -269,9 +277,11 @@ export function CsvTable({ onCopySelection, columnTypes, onChangeColumnTypes, + onProjectedCsvChange, }: CsvTableProps) { const rootRef = useRef(null); const gridWrapRef = useRef(null); + const columnsContainerRef = useRef(null); /** Source index of the last direct selection — the shift-click range anchor. */ const anchorRef = useRef(null); const [searchInput, setSearchInput] = useState(""); @@ -283,6 +293,9 @@ export function CsvTable({ const [editingHeader, setEditingHeader] = useState(null); const [headerDraft, setHeaderDraft] = useState(""); const headerInputRef = useRef(null); + const [hiddenCols, setHiddenCols] = useState>(() => new Set()); + const [columnsOpen, setColumnsOpen] = useState(false); + const [applyViewToOutput, setApplyViewToOutput] = useState(false); const [gridSelection, setGridSelection] = useState(undefined); /** Selected rows as SOURCE indices — survives filter and sort. */ const [selectedSources, setSelectedSources] = useState>(() => new Set()); @@ -324,10 +337,46 @@ export function CsvTable({ const numeric = useMemo(() => numericColumns(table), [table]); const columnsCount = table.headers.length; + // Reset hiddenCols when table.headers length/names change unless columns + // still match (e.g. row edits or single-column header rename). + const prevHeadersRef = useRef(table.headers); + if (prevHeadersRef.current !== table.headers) { + const prev = prevHeadersRef.current; + const curr = table.headers; + prevHeadersRef.current = curr; + const sameLength = prev.length === curr.length; + const diffCount = sameLength + ? curr.reduce((acc, h, i) => (h === prev[i] ? acc : acc + 1), 0) + : Infinity; + if (!sameLength || diffCount > 1 || (diffCount === 1 && curr.length <= 1)) { + if (hiddenCols.size > 0) { + setHiddenCols(new Set()); + } + } + } + + const visibleColIndices = useMemo(() => { + const all = Array.from({ length: columnsCount }, (_, i) => i); + if (hiddenCols.size === 0) return all; + const filtered = all.filter((i) => !hiddenCols.has(i)); + return filtered.length > 0 ? filtered : all; + }, [columnsCount, hiddenCols]); + const visibleColIndicesRef = useRef(visibleColIndices); + visibleColIndicesRef.current = visibleColIndices; + + const visibleTable = useMemo(() => { + if (visibleColIndices.length === table.headers.length) return table; + return { + ...table, + headers: visibleColIndices.map((c) => table.headers[c] ?? ""), + rows: table.rows.map((row) => visibleColIndices.map((c) => row[c] ?? "")), + }; + }, [table, visibleColIndices]); + // Latest-value mirrors: event handlers (copy key, edit commits, canvas // draw callbacks) must always see the props of the last completed render. - const latestRef = useRef({ text: effectiveText, table }); - latestRef.current = { text: effectiveText, table }; + const latestRef = useRef({ text: effectiveText, table, visibleTable }); + latestRef.current = { text: effectiveText, table, visibleTable }; const gridSelectionRef = useRef(gridSelection); gridSelectionRef.current = gridSelection; @@ -361,9 +410,80 @@ export function CsvTable({ const columnWidth = Math.max( COLUMN_MIN_WIDTH, - Math.floor((containerWidth - GUTTER_WIDTH) / Math.max(1, columnsCount)) + Math.floor((containerWidth - GUTTER_WIDTH) / Math.max(1, visibleColIndices.length)) ); + // Close the Columns popover on Escape or outside click. + useEffect(() => { + if (!columnsOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + setColumnsOpen(false); + } + }; + const onClickOutside = (event: MouseEvent) => { + const container = columnsContainerRef.current; + if (container === null || !(event.target instanceof Node)) return; + if (!container.contains(event.target)) setColumnsOpen(false); + }; + document.addEventListener("keydown", onKeyDown); + document.addEventListener("click", onClickOutside); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("click", onClickOutside); + }; + }, [columnsOpen]); + + const toggleColumnVisibility = useCallback( + (colIndex: number) => { + setHiddenCols((prev) => { + const next = new Set(prev); + if (next.has(colIndex)) { + next.delete(colIndex); + } else { + const visibleAfter = columnsCount - (next.size + 1); + if (visibleAfter < 1) return prev; + next.add(colIndex); + } + return next; + }); + setGridSelection((prev) => + prev && prev.columns.length > 0 + ? { ...prev, columns: CompactSelection.empty() } + : prev + ); + }, + [columnsCount] + ); + + const resetColumnsVisibility = useCallback(() => { + setHiddenCols(new Set()); + setGridSelection((prev) => + prev && prev.columns.length > 0 + ? { ...prev, columns: CompactSelection.empty() } + : prev + ); + }, []); + + const hasHiddenColumns = visibleColIndices.length < columnsCount; + const hasActiveViewTransform = query !== "" || sort !== null || hasHiddenColumns; + const projectedCsv = useMemo(() => { + if (!applyViewToOutput || !hasActiveViewTransform) return null; + return projectCsvView(table, view, visibleColIndices); + }, [applyViewToOutput, hasActiveViewTransform, table, view, visibleColIndices]); + + const onProjectedCsvChangeRef = useRef(onProjectedCsvChange); + onProjectedCsvChangeRef.current = onProjectedCsvChange; + useEffect(() => { + onProjectedCsvChangeRef.current?.(projectedCsv); + }, [projectedCsv]); + useEffect(() => { + return () => { + onProjectedCsvChangeRef.current?.(null); + }; + }, []); + // A new parse can shrink the grid. Drop source indices that no longer // exist so the toolbar count stays honest (copy filters through the view). useEffect(() => { @@ -427,7 +547,9 @@ export function CsvTable({ const handleHeaderClicked = useCallback( (colIndex: number, event?: HeaderClickEvent) => { if (colIndex < GUTTER_COLUMNS) return; // the gutter header is not sortable or editable - const col = colIndex - GUTTER_COLUMNS; + const visiblePos = colIndex - GUTTER_COLUMNS; + const col = visibleColIndicesRef.current[visiblePos]; + if (col === undefined) return; if (editable && event?.isDoubleClick) { setSort(preClickSortRef.current); const currentHeader = latestRef.current.table.headers[col] ?? ""; @@ -442,7 +564,7 @@ export function CsvTable({ height: rawBounds.height, } : rawBounds ?? { - x: GUTTER_WIDTH + col * columnWidth, + x: GUTTER_WIDTH + visiblePos * columnWidth, y: 0, width: columnWidth, height: HEADER_HEIGHT, @@ -575,8 +697,9 @@ export function CsvTable({ () => [...(gridSelection?.columns ?? [])] .filter((c) => c >= GUTTER_COLUMNS) - .map((c) => c - GUTTER_COLUMNS), - [gridSelection] + .map((c) => visibleColIndices[c - GUTTER_COLUMNS]) + .filter((col): col is number => col !== undefined), + [gridSelection, visibleColIndices] ); const selectedColumnCount = selectedDataColumns.length; const selectedColumnHeaders = useMemo( @@ -638,8 +761,8 @@ export function CsvTable({ ); const gutterColumns = GUTTER_COLUMNS; const selectionStats = useMemo( - () => computeSelectionStats(table, view, selection, gutterColumns), - [table, view, selection, gutterColumns] + () => computeSelectionStats(visibleTable, view, selection, gutterColumns), + [visibleTable, view, selection, gutterColumns] ); const showSelectionChip = selectedInView.length > 0 || selectedColumnCount > 0 || selectionStats !== null; @@ -650,7 +773,7 @@ export function CsvTable({ const copySelection = () => { const selection = gridSelectionRef.current; const payload = serializeGridSelection( - latestRef.current.table, + latestRef.current.visibleTable, viewRef.current, { ranges: selection?.current ? [selection.current.range, ...selection.current.rangeStack] : [], @@ -680,7 +803,7 @@ export function CsvTable({ } const selection = gridSelectionRef.current; const payload = serializeGridSelection( - latestRef.current.table, + latestRef.current.visibleTable, viewRef.current, { ranges: selection?.current ? [selection.current.range, ...selection.current.rangeStack] : [], @@ -789,8 +912,9 @@ export function CsvTable({ } if (selection.columns.length > 0) { const dataCols = [...selection.columns] - .filter((c) => c >= GUTTER_COLUMNS && c < GUTTER_COLUMNS + current.headers.length) - .map((c) => c - GUTTER_COLUMNS); + .filter((c) => c >= GUTTER_COLUMNS) + .map((c) => visibleColIndicesRef.current[c - GUTTER_COLUMNS]) + .filter((col): col is number => col !== undefined && col >= 0 && col < current.headers.length); // Last-column guard — same rule as the toolbar action. if (dataCols.length > 0 && dataCols.length < current.headers.length) { commitDeletion(deleteColumns(sourceText, current, dataCols)); @@ -833,9 +957,10 @@ export function CsvTable({ let working = sourceText; let delta = 0; for (const { location, value } of ordered) { - const dataCol = location[0] - GUTTER_COLUMNS; + const visiblePos = location[0] - GUTTER_COLUMNS; + const dataCol = visiblePos >= 0 ? visibleColIndicesRef.current[visiblePos] : undefined; const viewRow = location[1]; - if (dataCol < 0 || value.kind !== GridCellKind.Text) continue; + if (dataCol === undefined || dataCol < 0 || value.kind !== GridCellKind.Text) continue; const source = view[viewRow]; const span = source === undefined ? undefined : current.rowSpans[source]; if (!span) continue; @@ -872,7 +997,9 @@ export function CsvTable({ contentAlign: "right", }; } - const value = latestRef.current.table.rows[source]?.[col - GUTTER_COLUMNS] ?? ""; + const sourceCol = visibleColIndicesRef.current[col - GUTTER_COLUMNS]; + const value = + sourceCol === undefined ? "" : (latestRef.current.table.rows[source]?.[sourceCol] ?? ""); return { kind: GridCellKind.Text, data: value, @@ -908,8 +1035,8 @@ export function CsvTable({ [onGutterClick] ); - // Columns: the source-row gutter plus one per header. Uppercase in the - // title (canvas has no text-transform); numeric columns get the mono + // Columns: the source-row gutter plus one per visible header. Uppercase in + // the title (canvas has no text-transform); numeric columns get the mono // base font through a per-column theme override. Memoized — the grid // redraws when the array identity changes. const columns = useMemo(() => { @@ -919,19 +1046,20 @@ export function CsvTable({ width: GUTTER_WIDTH, themeOverride: { baseFontStyle: `11px ${MONO_FONT}`, textDark: "hsla(240, 3.8%, 46.1%, 0.4)" }, }; - const data: GridColumn[] = table.headers.map((header, index) => { + const data: GridColumn[] = visibleColIndices.map((sourceCol) => { + const header = table.headers[sourceCol] ?? ""; const badge = editable ? columnTypeBadge(effectiveColumnTypes[header]) : null; return { title: badge ? `${header.toUpperCase()} [${badge}]` : header.toUpperCase(), - id: `c${index}`, + id: `c${sourceCol}`, width: columnWidth, - themeOverride: numeric[index] ? { baseFontStyle: `12px ${MONO_FONT}` } : undefined, + themeOverride: numeric[sourceCol] ? { baseFontStyle: `12px ${MONO_FONT}` } : undefined, }; }); return [gutter, ...data]; - }, [table.headers, numeric, columnWidth, editable, effectiveColumnTypes]); + }, [table.headers, visibleColIndices, numeric, columnWidth, editable, effectiveColumnTypes]); // Header strip: mono uppercase muted text, sort glyph on the sorted // column, right-aligned "#" in the gutter. A selected column header @@ -960,7 +1088,8 @@ export function CsvTable({ return; } drawContent(); - const sortDir = sort && sort.col === columnIndex - GUTTER_COLUMNS ? sort.dir : null; + const sourceCol = visibleColIndices[columnIndex - GUTTER_COLUMNS]; + const sortDir = sort && sort.col === sourceCol ? sort.dir : null; if (sortDir !== null) { const cx = rect.x + rect.width - 11; const cy = rect.y + rect.height / 2; @@ -983,7 +1112,7 @@ export function CsvTable({ ctx.restore(); } }, - [sort] + [sort, visibleColIndices] ); // Cell layer: amber wash behind search matches (the DOM table's ), @@ -1108,6 +1237,97 @@ export function CsvTable({