From c53b6f235ae59bb1365eab48ad91f3dcf69675a4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:00:20 -0700 Subject: [PATCH 001/167] Edit CSV tables directly and remember column widths (#25442) * Add direct CSV table editing and persistent column widths * Preserve pending CSV edits across concurrent saves and panes * Keep CSV input stable during autosave and table commands * Cancel delayed menu paste after CSV pane focus changes * Prove delayed CSV menu paste starts before focus changes --- .../editor/CsvColumnResizeHandle.tsx | 5 +- .../src/components/editor/CsvGrid.tsx | 200 ++++++++-- .../src/components/editor/CsvGridCell.tsx | 106 +++++ .../src/components/editor/CsvPagedViewer.tsx | 20 + .../src/components/editor/CsvTableToolbar.tsx | 193 +++++++++ .../editor/CsvViewer.editing.test.tsx | 365 ++++++++++++++++++ .../src/components/editor/CsvViewer.tsx | 136 ++++++- .../editor/EditorEditFileSurface.tsx | 7 + .../components/editor/EditorViewToggle.tsx | 4 +- .../components/editor/csv-cell-clipboard.ts | 90 +++++ .../components/editor/csv-cell-selection.ts | 64 +++ .../editor/csv-column-width-limits.ts | 2 + .../csv-column-width-preferences.test.ts | 77 ++++ .../editor/csv-column-width-preferences.ts | 62 +++ .../src/components/editor/csv-file-limits.ts | 1 + .../components/editor/csv-grid-interaction.ts | 26 ++ .../components/editor/csv-grid-keyboard.ts | 115 ++++++ .../src/components/editor/csv-inspection.ts | 32 ++ .../src/components/editor/csv-parse.ts | 12 +- .../editor/csv-pending-save.test.ts | 127 ++++++ .../components/editor/csv-serialization.ts | 30 ++ .../components/editor/csv-source-record.ts | 12 + .../editor/csv-table-edit-budget.ts | 7 + .../editor/csv-table-interactions.test.ts | 70 ++++ .../components/editor/csv-text-cell-edits.ts | 81 ++++ .../editor/csv-text-document.test.ts | 147 +++++++ .../components/editor/csv-text-document.ts | 197 ++++++++++ .../editor/editor-csv-file-content.ts | 4 +- .../editor/editor-pending-flush.test.ts | 44 +++ .../components/editor/editor-pending-flush.ts | 32 +- .../components/editor/editor-save-queue.ts | 20 +- .../editor/file-view-preference-storage.ts | 69 ++++ .../editor/pdf-scale-preference-storage.ts | 82 +--- ...use-editor-content-change-handler.test.tsx | 48 +++ .../use-editor-content-change-handler.ts | 5 +- .../src/components/editor/useCsvClipboard.ts | 124 ++++++ .../components/editor/useCsvColumnWidths.ts | 39 ++ .../components/editor/useCsvPendingCell.ts | 93 +++++ .../components/editor/useCsvTableEditor.ts | 255 ++++++++++++ src/renderer/src/components/ui/textarea.tsx | 8 +- src/renderer/src/i18n/locales/en.json | 25 +- tests/e2e/csv-paged-preview.spec.ts | 11 + tests/e2e/csv-table-editing.spec.ts | 329 ++++++++++++++++ tests/e2e/csv-url-links.spec.ts | 16 +- 44 files changed, 3242 insertions(+), 150 deletions(-) create mode 100644 src/renderer/src/components/editor/CsvGridCell.tsx create mode 100644 src/renderer/src/components/editor/CsvTableToolbar.tsx create mode 100644 src/renderer/src/components/editor/CsvViewer.editing.test.tsx create mode 100644 src/renderer/src/components/editor/csv-cell-clipboard.ts create mode 100644 src/renderer/src/components/editor/csv-cell-selection.ts create mode 100644 src/renderer/src/components/editor/csv-column-width-limits.ts create mode 100644 src/renderer/src/components/editor/csv-column-width-preferences.test.ts create mode 100644 src/renderer/src/components/editor/csv-column-width-preferences.ts create mode 100644 src/renderer/src/components/editor/csv-file-limits.ts create mode 100644 src/renderer/src/components/editor/csv-grid-interaction.ts create mode 100644 src/renderer/src/components/editor/csv-grid-keyboard.ts create mode 100644 src/renderer/src/components/editor/csv-inspection.ts create mode 100644 src/renderer/src/components/editor/csv-pending-save.test.ts create mode 100644 src/renderer/src/components/editor/csv-serialization.ts create mode 100644 src/renderer/src/components/editor/csv-source-record.ts create mode 100644 src/renderer/src/components/editor/csv-table-edit-budget.ts create mode 100644 src/renderer/src/components/editor/csv-table-interactions.test.ts create mode 100644 src/renderer/src/components/editor/csv-text-cell-edits.ts create mode 100644 src/renderer/src/components/editor/csv-text-document.test.ts create mode 100644 src/renderer/src/components/editor/csv-text-document.ts create mode 100644 src/renderer/src/components/editor/editor-pending-flush.test.ts create mode 100644 src/renderer/src/components/editor/file-view-preference-storage.ts create mode 100644 src/renderer/src/components/editor/use-editor-content-change-handler.test.tsx create mode 100644 src/renderer/src/components/editor/useCsvClipboard.ts create mode 100644 src/renderer/src/components/editor/useCsvColumnWidths.ts create mode 100644 src/renderer/src/components/editor/useCsvPendingCell.ts create mode 100644 src/renderer/src/components/editor/useCsvTableEditor.ts create mode 100644 tests/e2e/csv-table-editing.spec.ts diff --git a/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx b/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx index ec2bca0f6294..0efdb568c708 100644 --- a/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx +++ b/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef } from 'react' import { translate } from '@/i18n/i18n' - -export const CSV_MIN_COLUMN_WIDTH = 48 -export const CSV_MAX_COLUMN_WIDTH = 1200 +import { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' +export { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' export function CsvColumnResizeHandle({ index, diff --git a/src/renderer/src/components/editor/CsvGrid.tsx b/src/renderer/src/components/editor/CsvGrid.tsx index 0f6eee7df753..9afe01942ad2 100644 --- a/src/renderer/src/components/editor/CsvGrid.tsx +++ b/src/renderer/src/components/editor/CsvGrid.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { CsvColumnResizeHandle } from './CsvColumnResizeHandle' -import { CsvCellValue } from './CsvCellValue' +import { CsvGridCell } from './CsvGridCell' +import type { CsvGridInteraction } from './csv-grid-interaction' +import type { CsvColumnWidths } from './csv-column-width-preferences' const ROW_HEIGHT = 28 // Keep the scroll surface below Chromium's layout extent limit. @@ -16,7 +18,10 @@ export function CsvGrid({ sampleRows, getRow, onVisibleRows, - onOpenUrl + onOpenUrl, + interaction, + columnWidths, + onColumnWidthsChange }: { header: string[] rowCount: number @@ -25,14 +30,23 @@ export function CsvGrid({ getRow: (index: number) => string[] | undefined onVisibleRows?: (first: number, last: number) => void onOpenUrl?: (url: string, event: React.MouseEvent) => void + interaction?: CsvGridInteraction + columnWidths?: CsvColumnWidths + onColumnWidthsChange?: (widths: CsvColumnWidths) => void }): React.JSX.Element { const scrollRef = useRef(null) + const gridRef = useRef(null) + const gridId = useId() + const dragging = useRef(false) + const scrolledFocusVersion = useRef(-1) + const focusGrid = (): void => gridRef.current?.focus({ preventScroll: true }) const [requestedWindowStart, setWindowStart] = useState(0) const windowStart = Math.min( requestedWindowStart, Math.floor(Math.max(0, rowCount - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS ) - const [widthOverrides, setWidthOverrides] = useState>({}) + const [localWidths, setLocalWidths] = useState({}) + const widthOverrides = columnWidths ?? localWidths const rowNumberWidth = Math.max(48, String(rowCount).length * 8 + 16) const widths = useMemo(() => { const result = Array.from({ length: columnCount }, () => 80) @@ -60,7 +74,7 @@ export function CsvGrid({ paddingStart: rowNumberWidth, overscan: 2 }) - useLayoutEffect(() => columns.measure(), [columns, widths]) + useLayoutEffect(() => columns.measure(), [columns, widths, columnWidths]) const virtualRows = rows.getVirtualItems() const virtualColumns = columns.getVirtualItems() const first = virtualRows[0]?.index @@ -72,12 +86,82 @@ export function CsvGrid({ }, [first, last, windowStart, onVisibleRows]) const resize = useCallback( (index: number, width: number) => { - setWidthOverrides((previous) => ({ ...previous, [index]: width })) + const next = { ...widthOverrides, [index]: width } + if (onColumnWidthsChange) { + onColumnWidthsChange(next) + } else { + setLocalWidths(next) + } columns.resizeItem(index, width) }, - [columns] + [columns, onColumnWidthsChange, widthOverrides] ) - const reset = useCallback((index: number) => resize(index, widths[index] ?? 80), [resize, widths]) + const reset = (index: number): void => { + const next = { ...widthOverrides } + delete next[index] + if (onColumnWidthsChange) { + onColumnWidthsChange(next) + } else { + setLocalWidths(next) + } + columns.resizeItem(index, widths[index] ?? 80) + } + const active = interaction?.selection?.focus + useLayoutEffect(() => { + if (!active || scrolledFocusVersion.current === interaction?.focusVersion) { + return + } + if (interaction?.editing) { + scrolledFocusVersion.current = interaction.focusVersion + return + } + const targetWindow = + Math.floor(Math.max(0, active.row - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS + if (targetWindow !== windowStart) { + setWindowStart(targetWindow) + return + } + if (active.row > 0) { + rows.scrollToIndex(active.row - 1 - windowStart, { align: 'auto' }) + } else { + scrollRef.current?.scrollTo({ top: 0 }) + } + columns.scrollToIndex(active.column, { align: 'auto' }) + scrolledFocusVersion.current = interaction?.focusVersion ?? -1 + }, [active, interaction?.editing, interaction?.focusVersion, rows, columns, windowStart]) + useEffect(() => { + const finish = (): void => { + dragging.current = false + } + window.addEventListener('pointerup', finish) + window.addEventListener('pointercancel', finish) + window.addEventListener('blur', finish) + return () => { + window.removeEventListener('pointerup', finish) + window.removeEventListener('pointercancel', finish) + window.removeEventListener('blur', finish) + } + }, []) + const pointerCell = (event: React.PointerEvent): { row: number; column: number } | null => { + if ( + !(event.target instanceof Element) || + event.target.closest('a,textarea,[role="separator"]') + ) { + return null + } + const cell = event.target.closest('[data-csv-row]') + if (!cell) { + return null + } + const row = Number(cell.getAttribute('data-csv-row')) + const column = Number(cell.getAttribute('data-csv-column')) + return Number.isInteger(row) && Number.isInteger(column) ? { row, column } : null + } + const activeMounted = + active && + virtualColumns.some((column) => column.index === active.column) && + (active.row === 0 || virtualRows.some((row) => row.index + windowStart + 1 === active.row)) + const totalWidth = columns.getTotalSize() const gridTemplate = `${rowNumberWidth}px ${Math.max(0, (virtualColumns[0]?.start ?? rowNumberWidth) - rowNumberWidth)}px ${virtualColumns.map((column) => `${column.size}px`).join(' ')} ${Math.max(0, totalWidth - (virtualColumns.at(-1)?.end ?? rowNumberWidth))}px` const moveWindow = (start: number): void => { @@ -90,9 +174,65 @@ export function CsvGrid({ ref={scrollRef} data-testid="csv-scroll" className="relative min-h-0 flex-1 overflow-auto scrollbar-editor font-mono text-xs" + onScroll={(event) => { + if (!interaction?.editing) { + return + } + const position = interaction.editing.position + const cell = gridRef.current + ?.querySelector( + `[data-csv-row="${position.row}"][data-csv-column="${position.column}"]` + ) + ?.getBoundingClientRect() + const viewport = event.currentTarget.getBoundingClientRect() + // Caret visibility can scroll a clipped input while the user is still typing. + if ( + cell && + cell.bottom > viewport.top + (position.row ? ROW_HEIGHT : 0) && + cell.top < viewport.bottom && + cell.right > viewport.left && + cell.left < viewport.right + ) { + return + } + if (!interaction.commit()) { + if (position.row > 0) { + rows.scrollToIndex(position.row - 1 - windowStart, { align: 'auto' }) + } + columns.scrollToIndex(position.column, { align: 'auto' }) + } + }} >
{ + const cell = pointerCell(event) + if (!interaction || !cell || event.button !== 0) { + return + } + event.preventDefault() + if (interaction.select(cell.row, cell.column, event.shiftKey)) { + dragging.current = true + focusGrid() + } + }} + onPointerMove={(event) => { + const cell = pointerCell(event) + if (interaction && dragging.current && cell) { + interaction.select(cell.row, cell.column, true) + } + }} aria-rowcount={rowCount + 1} aria-colcount={columnCount + 1} className="relative min-w-full" @@ -113,23 +253,23 @@ export function CsvGrid({