diff --git a/README.md b/README.md index 6b54e6a..82576c2 100644 --- a/README.md +++ b/README.md @@ -1,195 +1,77 @@ # CSV -View and edit `.csv`, `.tsv`, `.tab`, and `.psv` files as tables in VS Code. CSV supports multiline cells, selection and clipboard operations, column filters, frozen panes, and a text-view switch for working with the underlying file. +**Find the rows that matter. Make the fix. Stay in VS Code.** -Actual CSV table webview with example data, captured in Chromium. +A data export, a test fixture, a lookup table: sometimes you just need to check a few values and fix them. CSV gives you an editable table in the same workspace as your code, with visual filters, selection totals, and cell-by-cell comparisons. Open the file, work on the data, and save. -![CSV table in a dark theme](images/marketplace-dark.png) -![CSV table in a light theme](images/marketplace-light.png) +CSV, TSV, TAB & PSV · No account required -## Open and edit a file +![CSV table with aligned columns, datatype colors, and multiline notes in a dark theme](images/marketplace-dark.png) -Install **CSV** by **ReprEng** from the Extensions view, then open a supported file. CSV opens it as a table unless your editor associations select another editor. The default separators are comma for `.csv`, tab for `.tsv` and `.tab`, and pipe for `.psv`. +## Why choose CSV? -1. Click a cell to select it. Type to start a quick edit, or press Enter or double-click for detail editing. -2. Press Enter or Tab, click outside the cell, or move focus away to commit its text to the VS Code document. In quick edit, arrow keys also commit and move to the adjacent cell. In detail edit, arrows move the text caret; Shift+Enter inserts a line break. -3. Press Ctrl/Cmd+S to commit the active cell and save the document. Committing a cell alone does **not** write the file to disk; VS Code's autosave can save committed document changes if enabled. +Choose CSV when your next step is to **inspect and fix the data in place**. -Escape cancels the active cell draft. If VS Code rejects an edit, its draft stays available: retry it or press Escape to discard it before structural operations, switching views, or disabling the table. Changes remain unsaved until a save succeeds. - -An empty file can be edited immediately. The extra bottom row is an append affordance; entering a value creates source data, while leaving it empty does not. Disable `csv.showTrailingEmptyRow` to hide it. - -CSV declares support for VS Code **1.70 or later**. Saved-file table/text replacement requires **1.100** for clean documents and **1.120** for documents with unsaved changes. Older hosts show update guidance for that action. - -## Select, copy, paste, and arrange data - -Selection is visible as soon as you press the mouse button. Drag or Shift+click to select a range. Click a header to select its column; a second separate click on the same header selects only the header cell. Double-click edits the header. Click the serial index to select a row, or the top-left corner to select the table. Shift+click extends whole-row or whole-column selections. - -Use Ctrl/Cmd+C or X, or the selection's right-click **Copy** or **Cut** action. Clipboard data uses the active separator and quotes each value only when needed, preserving separators, line breaks, literal quotes, surrounding whitespace, and real empty cells. The serial index and extra append row are excluded. Selecting only the append row leaves the clipboard unchanged. Clipboard shortcuts inside cell edits, Find, Replace, and filter inputs keep their normal text-editing behavior. - -Paste with Ctrl/Cmd+V in selection mode or the right-click **Paste** action. A single value fills a selected rectangle; a copied matrix starts at the selection. With filters active, copying, cutting, filling, and matrix paste use matching source rows. Additional pasted rows append at the physical end of the file. Nonmatching rows stay intact, and saving includes the full file. - -Right-click cells to insert or delete rows and columns. Multi-selection actions use the selected count. Drag selected headers or row indices to reorder a column/row or selected group. Right-click a header to sort ascending or descending; with filters active, sorting rearranges matching rows within their source positions and leaves hidden rows in place. These operations change the document and require saving like other edits. Unchanged field quoting and existing line endings are preserved; new values follow a consistent existing column quote style where available. - -Context menus open toward the available space, flip above or left when needed, and scroll in small panes. Resizing the editor or changing CSV zoom dismisses an open menu. - -## Headers, metadata rows, widths, and frozen panes - -- **Header:** Right-click a cell in the first visible source row and choose **Use first row as header** or **Treat first row as data**. Hidden leading metadata is skipped; filtering does not promote an arbitrary matching row to the header. The Command Palette also provides **CSV: Toggle First Row as Header**. -- **Serial index:** Right-click an index cell or its top-left corner and choose **Hide serial index column**. Restore it with **CSV: Toggle Serial Index Column**. The numbers identify source rows and can have gaps while filtering. -- **Metadata:** **CSV: Hide First N Rows** hides leading records without deleting them. Blank input inherits `csv.defaultIgnoreRows`; an explicit `0` shows them even if the configured default is nonzero. -- **Widths:** Automatic column widths account for the longest line in multiline cells. Drag close to a column boundary to resize, including with headers off. Double-click the boundary to restore automatic sizing. Manual widths are remembered per file; narrow columns retain a selectable center. Drag the bottom edge of a serial-index cell to adjust its row height. -- **Word wrap:** Word wrap starts **off** for each file, showing single-line cutoff with an ellipsis. Use the wrap icon beside the table/text switch in the editor title bar to toggle it; hover for **Enable Word Wrap** or **Disable Word Wrap**. Automatic widths remain capped at 100 characters; manual widths and row heights stay intact. Cutoff lets long rows collapse or be resized shorter. The selected cell/range stays selected and its active cell stays in view. Editing always shows the full value, and copying, searching and saving use the complete text. The display choice is remembered with the table's webview state. -- **Frozen panes:** Right-click to freeze through a visible row or data column, or use **CSV: Freeze Leading Rows/Columns**. Enter `0` to unfreeze an axis. Counts are remembered per file. By default, the header stays frozen; with headers off, no rows freeze until requested. Hidden metadata and the append row do not count. Frozen rows follow measured multiline/zoomed heights and paint their divider seams; the serial index stays alongside frozen data columns. - -Header, serial-index, separator, hidden-row, manual-width, and frozen-pane preferences do not change CSV contents and are stored per file URI in VS Code workspace state. Scroll, selection, row heights, and zoom use webview state. Column colors follow the selected datatype palette (`default`, `cool`, or `warm`) or the theme foreground mode. Light, dark, and high-contrast themes retain that choice. CSV's zoom changes the table's text size independently of global editor zoom. - -## Live column filters - -Right-click a data column and choose **Filter this column** to add a condition above the table. Choose a column and operator, then type a value; results update as you type without replacing the focused input. **Add filter** and repeated context actions add conditions, including multiple conditions on the same column. All conditions combine with **AND**. Remove one condition or use **Clear all filters** to remove them all. - -![Two live column conditions combined with AND](images/marketplace-filters.png) - -Every column offers Contains, Does Not Contain, Regular Expression, Equals, Does Not Equal, Is Empty, and Is Not Empty. Number and date columns also offer `>`, `>=`, `<`, and `<=`. Types are inferred from the original body rows, excluding the effective header and hidden metadata, before any filters apply. A text column whose nonblank cells all parse as supported dates also offers date comparisons without changing its column colors. - -- Text comparisons ignore case. Numbers accept finite decimal/scientific values and consistent thousands grouping, and compare without rounding away significant digits. -- Boolean equality accepts `true/false`, `t/f`, `yes/no`, `y/n`, `on/off`, and `1/0`. -- Dates accept ISO/year-first forms, numeric dates with four-digit years, and English month names, with optional times and timezone offsets. Unzoned values use UTC. Ambiguous numeric dates use month/day/year: `03/04/2026` means March 4; `13/04/2026` is unambiguously April 13. Use ISO dates or month names to avoid ambiguity. Supported timestamps compare exactly, including sub-millisecond fractions. -- Regular Expression uses [RE2 syntax](https://github.com/google/re2/wiki/Syntax). Plain patterns ignore case; `/pattern/flags` supports `i`, `m`, and `s`. An explicit empty flag list (`/pattern/`) is case-sensitive. Lookaround and backreferences are unsupported. +| If you currently use: | What CSV will add for you: | +| --- | --- | +| **Editing raw CSV in VS Code** | Work with cells, rows, and columns directly. Paste a block, move a column, or edit a multiline value while CSV handles the separators and escaping. | +| **[Rainbow CSV](https://marketplace.visualstudio.com/items?itemName=mechatroner.rainbow-csv)** for colored columns and SQL-like queries | Use a visual grid with point-and-click filters, range editing, and selection totals. Narrow down a dataset and correct the matching cells without writing a query. | +| **[Edit CSV](https://marketplace.visualstudio.com/items?itemName=janisdd.vscode-edit-csv)** for a grid with an Apply workflow | Cell edits commit directly to the VS Code document, source edits refresh the table, and Ctrl/Cmd+S saves. Switch between table and text as the task changes. | -A zero-length value leaves a draft condition inactive, except Is Empty/Is Not Empty, which need no value. Literal spaces are valid text searches. Invalid or incomplete values show an inline error and retain the last valid results. Blank or invalid cell values do not match typed comparisons, including Does Not Equal; use Is Empty for empty cells. Incomplete IME text is not applied automatically after a view refresh. +## Find the few rows that need attention -Filtering scans the full editable document, retains source row identities, and does not delete source rows. Valid conditions are remembered per file and can contain sensitive data; see [Privacy and data handling](PRIVACY.md). Blank or invalid drafts are not persisted. +An export has thousands of rows; you need the overdue orders, missing IDs, or unusually large amounts. Right-click a column, choose **Filter this column**, and build the conditions visually. Results update as you type. Combine text, number, date, and empty-cell conditions across columns, or use a regular expression when you need one. -## Find, native search, and text view +Filters search the full editable document, including rows beyond the screen. Copy the matches, fill a selected range, or use Find and Replace to clean them up. **Filtering keeps the other rows in your file**, and range operations on the filtered view leave nonmatching rows intact. -Ctrl/Cmd+F opens table Find; Ctrl/Cmd+H opens Find and Replace. Match case, whole-word, regular expression, selection-only search, and single/all replacement are available. Table Find includes later chunks. With filters active, Find and Replace All use matching displayed rows and the header; hidden metadata and nonmatching rows are excluded. +![CSV narrowing a table with two live column filters combined with AND](images/marketplace-filters.png) -Ctrl/Cmd+Shift+F and Ctrl/Cmd+Shift+H remain VS Code's native Find/Replace in Files shortcuts. Alt-modified variants also stay outside the table overlay. +## Make one correction or clean up a whole range -Use the toolbar switch or **CSV: Switch Between Table and Text** to edit the same document as source text. For saved files, the target opens before the source view is replaced, so one representation remains in that editor group. Live cell edits commit first, unsaved changes remain unsaved, and unrelated tabs/groups are retained. If the file already has a table in another group, switch from that group. +Click a cell and type. Tab to the next one. Paste a block of data, fill a rectangle with one value, or drag selected rows and columns into the order you need. Right-click to insert, delete, or sort. -For a saved local `file:` URI without an authority, query, or fragment, choosing Text also installs a reversible file-path editor association. Native Find in Files can then open that file in text at the selected result while CSV remains enabled. The choice survives restart and Off/On; switching successfully back to Table restores only CSV's owned association. Other files keep their normal editor choice. Intervening user choices are preserved. Patterns use VS Code's case-insensitive matching, so case-distinct paths are not independently isolated. CSV reports longer/conflicting associations or settings restrictions that prevent native routing. +For long notes, double-click to edit the full value and use Shift+Enter for line breaks. CSV handles quoted separators and multiline cells, so you can concentrate on the content. Familiar copy, cut, paste, Find, and Replace shortcuts work in the table. -**Search boundary:** this does not add native search navigation inside a table. A cold search into a file still associated with the table may open the table without revealing the match. Remote/virtual URIs that cannot be isolated by these patterns can switch views, but need CSV turned off for native text search routing. **CSV: Toggle Extension On/Off** temporarily routes CSV's four selectors and explicit CSV associations to native text while off, then restores owned preferences when enabled again. It honors an explicit workspace `csv.enabled` value, otherwise it toggles the global value. Re-enabling upgrades the invoking saved text tab when its restored editor choice is CSV; other already-open text tabs keep their current view until reopened or explicitly switched. +## Check totals without writing a formula -## Untitled CSV and TSV +Select numeric cells and read **Sum, Average, and Count** in the VS Code status bar. Check a column total, compare a group of values, or sanity-check a filtered export without opening a spreadsheet or writing a script. -Choose CSV or TSV language mode in a new untitled text document to open a table before saving. The native text tab remains its unsaved owner. Switching the table to text commits the live cell and closes only the temporary table; switching back recreates a table against that same document. Language changes preserve pending edits before refreshing the table. +Totals follow your selection and committed edits. With filters active, they include only matching data rows; whole-column selections also include matching rows beyond the screen. Count is the number of selected numeric cells, with text, blanks, and headers excluded. -Use the table toolbar's **Save Untitled Document As…** action or Ctrl/Cmd+S in selection or editing mode. CSV commits the active cell, reveals the native owner, and invokes VS Code's normal Save As flow. The temporary table closes when the untitled document becomes a saved file. Cancelling Save As retains the unsaved owner and table. Native **File > Save** cannot directly target a standalone table panel; switch to text before using that native action. +## Keep your changes easy to review -Separator, header, serial-index, metadata, and filter controls work before saving. File encoding can be changed after saving. Untitled documents are not switched with the saved-file native replacement operation because associated-path untitled buffers require their existing owner. +A small data fix should be easy to spot in version control. CSV preserves unchanged field quoting and existing line endings, so surrounding formatting stays intact. Datatype detection helps with colors and filters while stored values remain text: an ID such as `00123` keeps its leading zeros. -## Large File Preview +Use the title-bar switch to inspect or edit the same document as raw text, then return to the table. Pending changes stay unsaved until you save, or VS Code autosave saves committed edits. -The normal editable view uses a full VS Code text document; chunked rendering does not bound source-memory use. To inspect a very large saved file without first allocating that document, right-click it in Explorer and choose **CSV: Open Large File Preview (Read-only)**, or run that command and pick a file. Opening the preview after an editable tab does not undo memory already allocated by VS Code. +Need to compare two exports? Select them in Explorer and choose **CSV: Compare Files as Table**. See changed cells side by side, navigate between changes, and keep both panes aligned as you scroll. This read-only comparison is a separate command; use VS Code's text diff for native Git/PR review. [Comparison details and size limits](docs/user-guide.md#compare-two-files-as-tables). -The preview reads bounded saved-file blocks and displays one page at a time. **Next**, **Previous**, and **Back to Start** navigate complete records, including quoted multiline fields. The first record is data. There is no editing, global search, sorting, or filtering, and unsaved edits in another view are not included. +## Keep context as you scroll -It supports valid **UTF-8**, an optional UTF-8 BOM, a single ASCII separator, and host-local `file:` URIs. It reads directly in the extension host; arbitrary remote filesystem-provider URIs and other encodings are unsupported. Use a per-file separator override or `extension`/`default` separator mode; automatic detection without an override produces an actionable error. +Freeze the identifier columns and leading rows you need to keep in sight. Resize columns, toggle word wrap for long notes, or hide leading metadata without deleting it. CSV remembers per-file preferences such as column widths, frozen panes, and filters so you can return to the same working view. -`csv.largeFilePreviewRows` sets a maximum of 1–10,000 records per page (default 1,000). Independent limits are 512 KiB of source records and 20,000 cells per page, 256 KiB per record, and 1,000 columns. Pages may be smaller. Malformed/oversized records produce errors instead of truncated data. Only the current page and up to 32 page positions are retained; **Previous** reaches that retained history. If the saved file changes, use **Back to Start**. +Datatype colors, table zoom, and support for light, dark, and high-contrast themes help you read the data comfortably. [See the light theme](images/marketplace-light.png). -## Compare two files as tables +## Try it on your next export -Select two files in Explorer and choose **CSV: Compare Files as Table**, or run the command from an active CSV/text document and select the modified file. With no active source, choose original and modified files. Both panes are read-only and use VS Code's decoded document text, including unsaved edits; editing a source refreshes the comparison. +1. **[Install CSV by ReprEng](https://marketplace.visualstudio.com/items?itemName=ReprEng.csv)** from the VS Code Marketplace, or search for `ReprEng.csv` in Extensions. +2. **Open a `.csv`, `.tsv`, `.tab`, or `.psv` file.** It opens as a table unless you have chosen another editor association. If needed, right-click the editor tab and choose **Reopen Editor With… → CSV**. +3. **Make a change and press Ctrl/Cmd+S.** Click a cell and type, or right-click a column to filter first. Escape cancels the active cell draft. -The comparison aligns records around insertions/deletions, highlights changed cells, and shows placeholders for missing records or fields. Both scroll axes stay synchronized and paired rows share their height. Use **Previous/Next Change** and **Previous/Next Page**. The first record is data; comparison uses parsed values and field presence, not quoting or line-ending formatting. +Working from scratch? Create an untitled document and choose CSV or TSV as its language mode to start a table before saving. Need a different delimiter? Run **CSV: Change CSV Separator**. -The existing `csv.maxFileSizeMB` limit applies. Hard per-input limits are 10 MiB of saved bytes before a new document opens, 5 MiB of decoded text, 10,000 records, 100,000 cells, and 1,000 columns; values are limited to 64 Ki characters and records to 256 Ki characters. A provider without size information must already have the document open. Pages are capped at 200 aligned records, 20,000 body cells, and 512 Ki characters. Oversized/malformed inputs show errors without truncation. Bounded alignment falls back to positional pairing with a visible notice when necessary. +CSV supports VS Code 1.70+. Switching saved files between table and text requires 1.100+, or 1.120+ when there are unsaved changes. [Editing and compatibility details](docs/user-guide.md#open-and-edit-a-file). -This command is a separate table comparison. It does not provide native Git/PR custom-editor diff navigation; stable APIs do not reliably expose both input URIs for that flow. +## Your files stay in your workspace -## File encoding +CSV requires no account or upload service and has no extension-specific analytics client. Processing runs in your VS Code environment, including the remote extension host when you use a remote workspace. [Privacy and data handling](PRIVACY.md). -Editable views use Unicode text decoded by VS Code. Run **CSV: Change File Encoding**, choose **Reopen with Encoding**, and select the original file's encoding to correct incorrectly displayed characters. Check the text before saving. Cancelling the unsaved-changes prompt stops the command without discarding your edits. **Save with Encoding** changes how the current text is written; it cannot repair characters already decoded incorrectly. Use the original file or a backup if a corrupted version was saved. +For a very large saved file, use **CSV: Open Large File Preview (Read-only)** from Explorer to inspect it a page at a time. The preview reads bounded blocks of host-local UTF-8 files; normal editing loads the full document. [Preview capabilities and limits](docs/user-guide.md#large-file-preview). -A nonmodal warning appears once per opened document containing the replacement character `�`, offering the encoding action. This is a possible decoding mismatch, not automatic encoding detection: `�` can be legitimate text, and other incorrect decodings may not produce it. CSV does not guess, transcode, or repair bytes automatically. The read-only preview's UTF-8 restriction is separate from this editable-view workflow. +**Ready to make your next CSV fix in place? [Install CSV](https://marketplace.visualstudio.com/items?itemName=ReprEng.csv).** -## Commands +## Help and reference -Run these from the Command Palette. Commands that change a view preference target the active CSV table unless noted. +[User guide](docs/user-guide.md) · [Keyboard shortcuts](docs/user-guide.md#keyboard-and-mouse-reference) · [Commands](docs/user-guide.md#commands) · [Settings](docs/user-guide.md#settings) · [Changelog](CHANGELOG.md) · [Report an issue](https://github.com/jonaraphael/csv/issues) -| Command | Purpose | -| --- | --- | -| **CSV: Switch Between Table and Text** (`csv.toggleEditor`) | Switch the active supported table/text view. | -| **CSV: Enable/Disable Word Wrap** (`csv.enableWordWrap` / `csv.disableWordWrap`) | Toggle wrapping in the active editable table; also available beside the table/text switch. | -| **CSV: Save Untitled Document As…** (`csv.saveUntitled`) | Commit and save the active untitled table through the native owner. | -| **CSV: Toggle Extension On/Off** (`csv.toggleExtension`) | Enable/disable CSV and synchronize native editor routing. | -| **CSV: Toggle First Row as Header** (`csv.toggleHeader`) | Change the file's header preference. | -| **CSV: Toggle Serial Index Column** (`csv.toggleSerialIndex`) | Show/hide the file's serial index. | -| **CSV: Change CSV Separator** (`csv.changeSeparator`) | Override the separator; blank input inherits configured rules. Enter `\t` for tab. | -| **CSV: Reset CSV Separator (Inherit)** (`csv.resetSeparator`) | Clear the file's separator override. | -| **CSV: Hide First N Rows** (`csv.changeIgnoreRows`) | Override the metadata-row count; blank input inherits the default. | -| **CSV: Freeze Leading Rows** (`csv.changeFrozenRows`) | Set the number of leading visible rows to freeze; `0` unfreezes. | -| **CSV: Freeze Leading Columns** (`csv.changeFrozenColumns`) | Set leading data columns to freeze; `0` unfreezes. | -| **CSV: Change File Encoding** (`csv.changeEncoding`) | Open VS Code's encoding picker for a saved file. | -| **CSV: Change Font Family** (`csv.changeFontFamily`) | Pick an extension-host font or inherit `editor.fontFamily`. | -| **CSV: Toggle Clickable Links** (`csv.toggleClickableLinks`) | Toggle link interaction globally. | -| **CSV: Open Large File Preview (Read-only)** (`csv.openLargeFilePreview`) | Open a saved host-local file in the bounded preview. | -| **CSV: Compare Files as Table** (`csv.compareFilesAsTable`) | Choose two source files for read-only table comparison. | - -## Settings - -All settings are under `csv.*` in VS Code Settings or `settings.json`. Font and mouse-wheel zoom settings are application-scoped. Per-file commands override the relevant inherited view defaults. - -| Setting | Default | Behavior | -| --- | --- | --- | -| `csv.enabled` | `true` | Enable the custom editor; the toggle command also manages reversible native routing. | -| `csv.fontFamily` | `""` | Inherit `editor.fontFamily`, or use the specified font family. | -| `csv.fontSize` | `0` | Inherit `editor.fontSize`, or use a positive pixel size. | -| `csv.mouseWheelZoom` | `true` | Allow Ctrl/Cmd+mouse wheel table zoom. | -| `csv.mouseWheelZoomInvert` | `false` | Reverse the table zoom direction. | -| `csv.cellPadding` | `4` | Vertical cell padding in pixels. | -| `csv.columnColorMode` | `"type"` | `type` uses datatype colors; `theme` uses theme foreground. | -| `csv.columnColorPalette` | `"default"` | `default`, `cool`, or `warm` when color mode is `type`. | -| `csv.diffUseThemeForeground` | `true` | Use theme foreground for CSV views recognized as diff contexts. | -| `csv.clickableLinks` | `true` | Enable Ctrl/Cmd+click on recognized links. | -| `csv.showTrailingEmptyRow` | `true` | Show the extra append row. | -| `csv.separatorMode` | `"extension"` | `extension` uses extension mapping, `auto` detects from content first, `default` uses the configured default. Per-file overrides take precedence. | -| `csv.defaultSeparator` | `","` | Fallback separator. Use `"\t"` in JSON for a tab character, or `"\\t"` for the supported literal escape. | -| `csv.separatorByExtension` | CSV comma; TSV/TAB tab; PSV pipe | Extension mapping; keys accept `.ext` or `ext`. | -| `csv.maxFileSizeMB` | `10` | Editable-view size prompt. `0` disables it; **Ignore Forever** sets it to `0`. This is not a memory cap. | -| `csv.defaultIgnoreRows` | `0` | Hidden leading metadata records when no per-file override exists. | -| `csv.largeFilePreviewRows` | `1000` | Preview records per page, from `1` to `10000`, subject to byte/cell caps. | - -Header mode, serial-index visibility, separator overrides, hidden rows, widths, frozen panes, and valid filter conditions are remembered per file. They change the view, except operations such as sorting, editing, and structural changes that explicitly modify document data. - -## Keyboard and mouse reference - -Ctrl/Cmd means Ctrl on Windows/Linux and Command on macOS. Table shortcuts apply outside text inputs unless noted. - -| Action | Shortcut or gesture | -| --- | --- | -| Select a cell/range | Click / drag / Shift+click | -| Select a row or column | Click its serial index or header; Shift+click extends the range | -| Select only a header cell | A second separate click on the same header | -| Select the table | Ctrl/Cmd+A, or click the top-left corner | -| Move selection | Arrow keys; holding a key keeps the target visible | -| Extend selection | Shift+Arrow | -| Jump to a table edge | Ctrl/Cmd+Arrow | -| Extend to an edge | Ctrl/Cmd+Shift+Arrow, retaining the starting anchor and row/column shape | -| Next/previous cell | Tab / Shift+Tab; wraps across data rows | -| Quick edit | Type on a selected cell; arrows commit and move | -| Detail edit | Enter or double-click; arrows move the caret | -| Commit and move while editing | Enter moves down; Tab/Shift+Tab moves across | -| Insert a cell line break | Shift+Enter | -| Cancel a draft | Escape; a failed edit restores its current source value | -| Save | Ctrl/Cmd+S commits the active cell, then saves (Save As for untitled) | -| Copy / cut / paste | Ctrl/Cmd+C / X / V, or right-click actions | -| Clear selected values | Delete / Backspace | -| Table Find / Replace | Ctrl/Cmd+F / H | -| Native Find / Replace in Files | Ctrl/Cmd+Shift+F / H | -| Next/previous Find match | F3 / Shift+F3; Enter/Shift+Enter in Find; Ctrl/Cmd+G/Shift+G while Find is open | -| Resize / reset a column | Drag / double-click near its boundary | -| Resize a row | Drag the bottom edge of its serial-index cell | -| Reorder rows/columns | Drag selected serial indices/headers | -| Zoom in / out / reset | Ctrl/Cmd+plus / minus / 0; Ctrl/Cmd+mouse wheel if enabled | -| Open a recognized link | Ctrl/Cmd+click | - -## Privacy and development - -See [Privacy and data handling](PRIVACY.md) for local/remote processing, clipboard and link actions, diagnostics, and saved preferences. CSV has no upload service or extension-specific analytics client. Remote workspaces can process files in the remote extension host. - -For development, use Node 20 and run `npm ci`, `npm test`, and `npm run lint`. `npm run package` creates the VSIX. See [the changelog](CHANGELOG.md) for release history and [REVIEW.md](REVIEW.md) for repository priorities. +For local development and verification, see the [development notes](docs/user-guide.md#privacy-and-development) and [repository priorities](REVIEW.md). diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..d937fa8 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,208 @@ +# CSV user guide + +For an overview and installation, see [CSV for VS Code](../README.md). + +Use this guide for editing behavior, commands, settings, and compatibility details. + +- [Open and edit a file](#open-and-edit-a-file) +- [Select, copy, paste, and arrange data](#select-copy-paste-and-arrange-data) +- [Headers, metadata rows, widths, and frozen panes](#headers-metadata-rows-widths-and-frozen-panes) +- [Live column filters](#live-column-filters) +- [Find, native search, and text view](#find-native-search-and-text-view) +- [Untitled CSV and TSV](#untitled-csv-and-tsv) +- [Large File Preview](#large-file-preview) +- [Compare two files as tables](#compare-two-files-as-tables) +- [File encoding](#file-encoding) +- [Commands](#commands) +- [Settings](#settings) +- [Keyboard and mouse reference](#keyboard-and-mouse-reference) +- [Privacy and development](#privacy-and-development) + +## Open and edit a file + +Install **CSV** by **ReprEng** from the Extensions view, then open a supported file. CSV opens it as a table unless your editor associations select another editor. The default separators are comma for `.csv`, tab for `.tsv` and `.tab`, and pipe for `.psv`. + +1. Click a cell to select it. Type to start a quick edit, or press Enter or double-click for detail editing. +2. Press Enter or Tab, click outside the cell, or move focus away to commit its text to the VS Code document. In quick edit, arrow keys also commit and move to the adjacent cell. In detail edit, arrows move the text caret; Shift+Enter inserts a line break. +3. Press Ctrl/Cmd+S to commit the active cell and save the document. Committing a cell alone does **not** write the file to disk; VS Code's autosave can save committed document changes if enabled. + +Escape cancels the active cell draft. If VS Code rejects an edit, its draft stays available: retry it or press Escape to discard it before structural operations, switching views, or disabling the table. Changes remain unsaved until a save succeeds. + +An empty file can be edited immediately. The extra bottom row is an append affordance; entering a value creates source data, while leaving it empty does not. Disable `csv.showTrailingEmptyRow` to hide it. + +CSV declares support for VS Code **1.70 or later**. Saved-file table/text replacement requires **1.100** for clean documents and **1.120** for documents with unsaved changes. Older hosts show update guidance for that action. + +## Select, copy, paste, and arrange data + +Selection is visible as soon as you press the mouse button. Drag or Shift+click to select a range. Click a header to select its column; a second separate click on the same header selects only the header cell. Double-click edits the header. Click the serial index to select a row, or the top-left corner to select the table. Shift+click extends whole-row or whole-column selections. + +The VS Code status bar shows **Sum**, **Average**, and **Count** for selected numeric data cells. Count includes only numbers; blanks, text, headers, the serial index, hidden rows, and the extra append row are excluded. Decimal/scientific values and consistent thousands grouping are supported. Whole-column and whole-table totals include matching rows that have not loaded on screen yet. The summary updates with selection and committed edits, and hides when no numeric cells are selected or another editor is active. + +Use Ctrl/Cmd+C or X, or the selection's right-click **Copy** or **Cut** action. Clipboard data uses the active separator and quotes each value only when needed, preserving separators, line breaks, literal quotes, surrounding whitespace, and real empty cells. The serial index and extra append row are excluded. Selecting only the append row leaves the clipboard unchanged. Clipboard shortcuts inside cell edits, Find, Replace, and filter inputs keep their normal text-editing behavior. + +Paste with Ctrl/Cmd+V in selection mode or the right-click **Paste** action. A single value fills a selected rectangle; a copied matrix starts at the selection. With filters active, copying, cutting, filling, and matrix paste use matching source rows. Additional pasted rows append at the physical end of the file. Nonmatching rows stay intact, and saving includes the full file. + +Right-click cells to insert or delete rows and columns. Multi-selection actions use the selected count. Drag selected headers or row indices to reorder a column/row or selected group. Right-click a header to sort ascending or descending; with filters active, sorting rearranges matching rows within their source positions and leaves hidden rows in place. These operations change the document and require saving like other edits. Unchanged field quoting and existing line endings are preserved; new values follow a consistent existing column quote style where available. + +Context menus open toward the available space, flip above or left when needed, and scroll in small panes. Resizing the editor or changing CSV zoom dismisses an open menu. + +## Headers, metadata rows, widths, and frozen panes + +- **Header:** Right-click a cell in the first visible source row and choose **Use first row as header** or **Treat first row as data**. Hidden leading metadata is skipped; filtering does not promote an arbitrary matching row to the header. The Command Palette also provides **CSV: Toggle First Row as Header**. +- **Serial index:** Right-click an index cell or its top-left corner and choose **Hide serial index column**. Restore it with **CSV: Toggle Serial Index Column**. The numbers identify source rows and can have gaps while filtering. +- **Metadata:** **CSV: Hide First N Rows** hides leading records without deleting them. Blank input inherits `csv.defaultIgnoreRows`; an explicit `0` shows them even if the configured default is nonzero. +- **Widths:** Automatic column widths account for the longest line in multiline cells. Drag close to a column boundary to resize, including with headers off. Double-click the boundary to restore automatic sizing. Manual widths are remembered per file; narrow columns retain a selectable center. Drag the bottom edge of a serial-index cell to adjust its row height. +- **Word wrap:** Word wrap starts **off** for each file, showing single-line cutoff with an ellipsis. Use the wrap icon beside the table/text switch in the editor title bar to toggle it; hover for **Enable Word Wrap** or **Disable Word Wrap**. Automatic widths remain capped at 100 characters; manual widths and row heights stay intact. Cutoff lets long rows collapse or be resized shorter. The selected cell/range stays selected and its active cell stays in view. Editing always shows the full value, and copying, searching and saving use the complete text. The display choice is remembered with the table's webview state. +- **Frozen panes:** Right-click to freeze through a visible row or data column, or use **CSV: Freeze Leading Rows/Columns**. Enter `0` to unfreeze an axis. Counts are remembered per file. By default, the header stays frozen; with headers off, no rows freeze until requested. Hidden metadata and the append row do not count. Frozen rows follow measured multiline/zoomed heights and paint their divider seams; the serial index stays alongside frozen data columns. + +Header, serial-index, separator, hidden-row, manual-width, and frozen-pane preferences do not change CSV contents and are stored per file URI in VS Code workspace state. Scroll, selection, row heights, and zoom use webview state. Column colors follow the selected datatype palette (`default`, `cool`, or `warm`) or the theme foreground mode. Light, dark, and high-contrast themes retain that choice. CSV's zoom changes the table's text size independently of global editor zoom. + +## Live column filters + +Right-click a data column and choose **Filter this column** to add a condition above the table. Choose a column and operator, then type a value; results update as you type without replacing the focused input. **Add filter** and repeated context actions add conditions, including multiple conditions on the same column. All conditions combine with **AND**. Remove one condition or use **Clear all filters** to remove them all. + +![Two live column conditions combined with AND](../images/marketplace-filters.png) + +Every column offers Contains, Does Not Contain, Regular Expression, Equals, Does Not Equal, Is Empty, and Is Not Empty. Number and date columns also offer `>`, `>=`, `<`, and `<=`. Types are inferred from the original body rows, excluding the effective header and hidden metadata, before any filters apply. A text column whose nonblank cells all parse as supported dates also offers date comparisons without changing its column colors. + +- Text comparisons ignore case. Numbers accept finite decimal/scientific values and consistent thousands grouping, and compare without rounding away significant digits. +- Boolean equality accepts `true/false`, `t/f`, `yes/no`, `y/n`, `on/off`, and `1/0`. +- Dates accept ISO/year-first forms, numeric dates with four-digit years, and English month names, with optional times and timezone offsets. Unzoned values use UTC. Ambiguous numeric dates use month/day/year: `03/04/2026` means March 4; `13/04/2026` is unambiguously April 13. Use ISO dates or month names to avoid ambiguity. Supported timestamps compare exactly, including sub-millisecond fractions. +- Regular Expression uses [RE2 syntax](https://github.com/google/re2/wiki/Syntax). Plain patterns ignore case; `/pattern/flags` supports `i`, `m`, and `s`. An explicit empty flag list (`/pattern/`) is case-sensitive. Lookaround and backreferences are unsupported. + +A zero-length value leaves a draft condition inactive, except Is Empty/Is Not Empty, which need no value. Literal spaces are valid text searches. Invalid or incomplete values show an inline error and retain the last valid results. Blank or invalid cell values do not match typed comparisons, including Does Not Equal; use Is Empty for empty cells. Incomplete IME text is not applied automatically after a view refresh. + +Filtering scans the full editable document, retains source row identities, and does not delete source rows. Valid conditions are remembered per file and can contain sensitive data; see [Privacy and data handling](../PRIVACY.md). Blank or invalid drafts are not persisted. + +## Find, native search, and text view + +Ctrl/Cmd+F opens table Find; Ctrl/Cmd+H opens Find and Replace. Match case, whole-word, regular expression, selection-only search, and single/all replacement are available. Table Find includes later chunks. With filters active, Find and Replace All use matching displayed rows and the header; hidden metadata and nonmatching rows are excluded. + +Ctrl/Cmd+Shift+F and Ctrl/Cmd+Shift+H remain VS Code's native Find/Replace in Files shortcuts. Alt-modified variants also stay outside the table overlay. + +Use the toolbar switch or **CSV: Switch Between Table and Text** to edit the same document as source text. For saved files, the target opens before the source view is replaced, so one representation remains in that editor group. Live cell edits commit first, unsaved changes remain unsaved, and unrelated tabs/groups are retained. If the file already has a table in another group, switch from that group. + +For a saved local `file:` URI without an authority, query, or fragment, choosing Text also installs a reversible file-path editor association. Native Find in Files can then open that file in text at the selected result while CSV remains enabled. The choice survives restart and Off/On; switching successfully back to Table restores only CSV's owned association. Other files keep their normal editor choice. Intervening user choices are preserved. Patterns use VS Code's case-insensitive matching, so case-distinct paths are not independently isolated. CSV reports longer/conflicting associations or settings restrictions that prevent native routing. + +**Search boundary:** this does not add native search navigation inside a table. A cold search into a file still associated with the table may open the table without revealing the match. Remote/virtual URIs that cannot be isolated by these patterns can switch views, but need CSV turned off for native text search routing. **CSV: Toggle Extension On/Off** temporarily routes CSV's four selectors and explicit CSV associations to native text while off, then restores owned preferences when enabled again. It honors an explicit workspace `csv.enabled` value, otherwise it toggles the global value. Re-enabling upgrades the invoking saved text tab when its restored editor choice is CSV; other already-open text tabs keep their current view until reopened or explicitly switched. + +## Untitled CSV and TSV + +Choose CSV or TSV language mode in a new untitled text document to open a table before saving. The native text tab remains its unsaved owner. Switching the table to text commits the live cell and closes only the temporary table; switching back recreates a table against that same document. Language changes preserve pending edits before refreshing the table. + +Use the table toolbar's **Save Untitled Document As…** action or Ctrl/Cmd+S in selection or editing mode. CSV commits the active cell, reveals the native owner, and invokes VS Code's normal Save As flow. The temporary table closes when the untitled document becomes a saved file. Cancelling Save As retains the unsaved owner and table. Native **File > Save** cannot directly target a standalone table panel; switch to text before using that native action. + +Separator, header, serial-index, metadata, and filter controls work before saving. File encoding can be changed after saving. Untitled documents are not switched with the saved-file native replacement operation because associated-path untitled buffers require their existing owner. + +## Large File Preview + +The normal editable view uses a full VS Code text document; chunked rendering does not bound source-memory use. To inspect a very large saved file without first allocating that document, right-click it in Explorer and choose **CSV: Open Large File Preview (Read-only)**, or run that command and pick a file. Opening the preview after an editable tab does not undo memory already allocated by VS Code. + +The preview reads bounded saved-file blocks and displays one page at a time. **Next**, **Previous**, and **Back to Start** navigate complete records, including quoted multiline fields. The first record is data. There is no editing, global search, sorting, or filtering, and unsaved edits in another view are not included. + +It supports valid **UTF-8**, an optional UTF-8 BOM, a single ASCII separator, and host-local `file:` URIs. It reads directly in the extension host; arbitrary remote filesystem-provider URIs and other encodings are unsupported. Use a per-file separator override or `extension`/`default` separator mode; automatic detection without an override produces an actionable error. + +`csv.largeFilePreviewRows` sets a maximum of 1–10,000 records per page (default 1,000). Independent limits are 512 KiB of source records and 20,000 cells per page, 256 KiB per record, and 1,000 columns. Pages may be smaller. Malformed/oversized records produce errors instead of truncated data. Only the current page and up to 32 page positions are retained; **Previous** reaches that retained history. If the saved file changes, use **Back to Start**. + +## Compare two files as tables + +Select two files in Explorer and choose **CSV: Compare Files as Table**, or run the command from an active CSV/text document and select the modified file. With no active source, choose original and modified files. Both panes are read-only and use VS Code's decoded document text, including unsaved edits; editing a source refreshes the comparison. + +The comparison aligns records around insertions/deletions, highlights changed cells, and shows placeholders for missing records or fields. Both scroll axes stay synchronized and paired rows share their height. Use **Previous/Next Change** and **Previous/Next Page**. The first record is data; comparison uses parsed values and field presence, not quoting or line-ending formatting. + +The existing `csv.maxFileSizeMB` limit applies. Hard per-input limits are 10 MiB of saved bytes before a new document opens, 5 MiB of decoded text, 10,000 records, 100,000 cells, and 1,000 columns; values are limited to 64 Ki characters and records to 256 Ki characters. A provider without size information must already have the document open. Pages are capped at 200 aligned records, 20,000 body cells, and 512 Ki characters. Oversized/malformed inputs show errors without truncation. Bounded alignment falls back to positional pairing with a visible notice when necessary. + +This command is a separate table comparison. It does not provide native Git/PR custom-editor diff navigation; stable APIs do not reliably expose both input URIs for that flow. + +## File encoding + +Editable views use Unicode text decoded by VS Code. Run **CSV: Change File Encoding**, choose **Reopen with Encoding**, and select the original file's encoding to correct incorrectly displayed characters. Check the text before saving. Cancelling the unsaved-changes prompt stops the command without discarding your edits. **Save with Encoding** changes how the current text is written; it cannot repair characters already decoded incorrectly. Use the original file or a backup if a corrupted version was saved. + +A nonmodal warning appears once per opened document containing the replacement character `�`, offering the encoding action. This is a possible decoding mismatch, not automatic encoding detection: `�` can be legitimate text, and other incorrect decodings may not produce it. CSV does not guess, transcode, or repair bytes automatically. The read-only preview's UTF-8 restriction is separate from this editable-view workflow. + +## Commands + +Run these from the Command Palette. Commands that change a view preference target the active CSV table unless noted. + +| Command | Purpose | +| --- | --- | +| **CSV: Switch Between Table and Text** (`csv.toggleEditor`) | Switch the active supported table/text view. | +| **CSV: Enable/Disable Word Wrap** (`csv.enableWordWrap` / `csv.disableWordWrap`) | Toggle wrapping in the active editable table; also available beside the table/text switch. | +| **CSV: Save Untitled Document As…** (`csv.saveUntitled`) | Commit and save the active untitled table through the native owner. | +| **CSV: Toggle Extension On/Off** (`csv.toggleExtension`) | Enable/disable CSV and synchronize native editor routing. | +| **CSV: Toggle First Row as Header** (`csv.toggleHeader`) | Change the file's header preference. | +| **CSV: Toggle Serial Index Column** (`csv.toggleSerialIndex`) | Show/hide the file's serial index. | +| **CSV: Change CSV Separator** (`csv.changeSeparator`) | Override the separator; blank input inherits configured rules. Enter `\t` for tab. | +| **CSV: Reset CSV Separator (Inherit)** (`csv.resetSeparator`) | Clear the file's separator override. | +| **CSV: Hide First N Rows** (`csv.changeIgnoreRows`) | Override the metadata-row count; blank input inherits the default. | +| **CSV: Freeze Leading Rows** (`csv.changeFrozenRows`) | Set the number of leading visible rows to freeze; `0` unfreezes. | +| **CSV: Freeze Leading Columns** (`csv.changeFrozenColumns`) | Set leading data columns to freeze; `0` unfreezes. | +| **CSV: Change File Encoding** (`csv.changeEncoding`) | Open VS Code's encoding picker for a saved file. | +| **CSV: Change Font Family** (`csv.changeFontFamily`) | Pick an extension-host font or inherit `editor.fontFamily`. | +| **CSV: Toggle Clickable Links** (`csv.toggleClickableLinks`) | Toggle link interaction globally. | +| **CSV: Open Large File Preview (Read-only)** (`csv.openLargeFilePreview`) | Open a saved host-local file in the bounded preview. | +| **CSV: Compare Files as Table** (`csv.compareFilesAsTable`) | Choose two source files for read-only table comparison. | + +## Settings + +All settings are under `csv.*` in VS Code Settings or `settings.json`. Font and mouse-wheel zoom settings are application-scoped. Per-file commands override the relevant inherited view defaults. + +| Setting | Default | Behavior | +| --- | --- | --- | +| `csv.enabled` | `true` | Enable the custom editor; the toggle command also manages reversible native routing. | +| `csv.fontFamily` | `""` | Inherit `editor.fontFamily`, or use the specified font family. | +| `csv.fontSize` | `0` | Inherit `editor.fontSize`, or use a positive pixel size. | +| `csv.mouseWheelZoom` | `true` | Allow Ctrl/Cmd+mouse wheel table zoom. | +| `csv.mouseWheelZoomInvert` | `false` | Reverse the table zoom direction. | +| `csv.cellPadding` | `4` | Vertical cell padding in pixels. | +| `csv.columnColorMode` | `"type"` | `type` uses datatype colors; `theme` uses theme foreground. | +| `csv.columnColorPalette` | `"default"` | `default`, `cool`, or `warm` when color mode is `type`. | +| `csv.diffUseThemeForeground` | `true` | Use theme foreground for CSV views recognized as diff contexts. | +| `csv.clickableLinks` | `true` | Enable Ctrl/Cmd+click on recognized links. | +| `csv.showTrailingEmptyRow` | `true` | Show the extra append row. | +| `csv.separatorMode` | `"extension"` | `extension` uses extension mapping, `auto` detects from content first, `default` uses the configured default. Per-file overrides take precedence. | +| `csv.defaultSeparator` | `","` | Fallback separator. Use `"\t"` in JSON for a tab character, or `"\\t"` for the supported literal escape. | +| `csv.separatorByExtension` | CSV comma; TSV/TAB tab; PSV pipe | Extension mapping; keys accept `.ext` or `ext`. | +| `csv.maxFileSizeMB` | `10` | Editable-view size prompt. `0` disables it; **Ignore Forever** sets it to `0`. This is not a memory cap. | +| `csv.defaultIgnoreRows` | `0` | Hidden leading metadata records when no per-file override exists. | +| `csv.largeFilePreviewRows` | `1000` | Preview records per page, from `1` to `10000`, subject to byte/cell caps. | + +Header mode, serial-index visibility, separator overrides, hidden rows, widths, frozen panes, and valid filter conditions are remembered per file. They change the view, except operations such as sorting, editing, and structural changes that explicitly modify document data. + +## Keyboard and mouse reference + +Ctrl/Cmd means Ctrl on Windows/Linux and Command on macOS. Table shortcuts apply outside text inputs unless noted. + +| Action | Shortcut or gesture | +| --- | --- | +| Select a cell/range | Click / drag / Shift+click | +| Select a row or column | Click its serial index or header; Shift+click extends the range | +| Select only a header cell | A second separate click on the same header | +| Select the table | Ctrl/Cmd+A, or click the top-left corner | +| Move selection | Arrow keys; holding a key keeps the target visible | +| Extend selection | Shift+Arrow | +| Jump to a table edge | Ctrl/Cmd+Arrow | +| Extend to an edge | Ctrl/Cmd+Shift+Arrow, retaining the starting anchor and row/column shape | +| Next/previous cell | Tab / Shift+Tab; wraps across data rows | +| Quick edit | Type on a selected cell; arrows commit and move | +| Detail edit | Enter or double-click; arrows move the caret | +| Commit and move while editing | Enter moves down; Tab/Shift+Tab moves across | +| Insert a cell line break | Shift+Enter | +| Cancel a draft | Escape; a failed edit restores its current source value | +| Save | Ctrl/Cmd+S commits the active cell, then saves (Save As for untitled) | +| Copy / cut / paste | Ctrl/Cmd+C / X / V, or right-click actions | +| Clear selected values | Delete / Backspace | +| Table Find / Replace | Ctrl/Cmd+F / H | +| Native Find / Replace in Files | Ctrl/Cmd+Shift+F / H | +| Next/previous Find match | F3 / Shift+F3; Enter/Shift+Enter in Find; Ctrl/Cmd+G/Shift+G while Find is open | +| Resize / reset a column | Drag / double-click near its boundary | +| Resize a row | Drag the bottom edge of its serial-index cell | +| Reorder rows/columns | Drag selected serial indices/headers | +| Zoom in / out / reset | Ctrl/Cmd+plus / minus / 0; Ctrl/Cmd+mouse wheel if enabled | +| Open a recognized link | Ctrl/Cmd+click | + +## Privacy and development + +See [Privacy and data handling](../PRIVACY.md) for local/remote processing, clipboard and link actions, diagnostics, and saved preferences. CSV has no upload service or extension-specific analytics client. Remote workspaces can process files in the remote extension host. + +For development, use Node 20 and run `npm ci`, `npm test`, and `npm run lint`. `npm run package` creates the VSIX. See [the changelog](../CHANGELOG.md) for release history and [REVIEW.md](../REVIEW.md) for repository priorities. diff --git a/images/selection-statistics.png b/images/selection-statistics.png new file mode 100644 index 0000000..5c92e98 Binary files /dev/null and b/images/selection-statistics.png differ diff --git a/media/main.js b/media/main.js index 61cc43b..349362b 100644 --- a/media/main.js +++ b/media/main.js @@ -465,7 +465,35 @@ window.addEventListener('wheel', handleZoomWheel, { passive: false }); let hasHeader = document.querySelector('thead') !== null; let headerEnabled = root?.dataset?.headerenabled === '1'; const getCellCoords = cell => ({ row: parseInt(cell.getAttribute('data-row')), col: parseInt(cell.getAttribute('data-col')) }); -const clearSelection = () => { currentSelection.forEach(c => c.classList.remove('selected')); currentSelection = []; }; +let numericSelectionScope = null; +let selectionReportScheduled = false; +const scheduleSelectionReport = () => { + if (selectionReportScheduled) return; + selectionReportScheduled = true; + requestAnimationFrame(() => { + selectionReportScheduled = false; + let selection = editingCell ? null : numericSelectionScope; + if (!selection && !editingCell) { + let minRow = Infinity, maxRow = -1, minCol = Infinity, maxCol = -1; + for (const cell of currentSelection) { + if (!cell.classList.contains('selected')) continue; + const { row, col } = getCellCoords(cell); + if (row < 0 || col < 0) continue; + minRow = Math.min(minRow, row); maxRow = Math.max(maxRow, row); + minCol = Math.min(minCol, col); maxCol = Math.max(maxCol, col); + } + selection = maxRow >= 0 && maxCol >= 0 ? { kind: 'cells', minRow, maxRow, minCol, maxCol } : null; + } + vscode.postMessage({ type: 'selectionChanged', selection, session: filterSession, filterEpoch }); + }); +}; +const clearSelection = () => { + currentSelection.forEach(c => c.classList.remove('selected')); + currentSelection = []; + numericSelectionScope = null; + // Read the final selection after synchronous mouse/keyboard handlers finish. + scheduleSelectionReport(); +}; const contextMenu = document.getElementById('contextMenu'); /* ──────── UPDATED showContextMenu ──────── */ @@ -1163,6 +1191,7 @@ const selectRange = (start, end) => { clearSelection(); const minRow = Math.min(start.row, end.row), maxRow = Math.max(start.row, end.row); const minCol = Math.min(start.col, end.col), maxCol = Math.max(start.col, end.col); + numericSelectionScope = { kind: 'cells', minRow, maxRow, minCol: Math.max(0, minCol), maxCol }; const selectedRows = visibleRowOrder.length ? visibleRowOrder.filter(row => row >= minRow && row <= maxRow) : Array.from({ length: maxRow - minRow + 1 }, (_, index) => minRow + index); for (const r of selectedRows) { for(let c = minCol; c <= maxCol; c++){ @@ -1176,6 +1205,7 @@ const selectRange = (start, end) => { const selectFullColumnRange = (col1, col2) => { clearSelection(); const minCol = Math.min(col1, col2), maxCol = Math.max(col1, col2); + numericSelectionScope = { kind: 'columns', minCol: Math.max(0, minCol), maxCol }; table.querySelectorAll('tr').forEach(row => { Array.from(row.children).forEach(cell => { const cellCol = cell.getAttribute('data-col'); @@ -1189,6 +1219,7 @@ const selectFullColumnRange = (col1, col2) => { const selectFullRowRange = (row1, row2) => { clearSelection(); const minRow = Math.min(row1, row2), maxRow = Math.max(row1, row2); + numericSelectionScope = { kind: 'rows', minRow, maxRow }; table.querySelectorAll('tr').forEach(row => { Array.from(row.children).forEach(cell => { const r = cell.getAttribute('data-row'); @@ -1199,6 +1230,19 @@ const selectFullRowRange = (row1, row2) => { }); }; +// Newly loaded cells belong to an existing whole-column/table or range selection. +window.addEventListener('csvChunkLoaded', () => { + const scope = numericSelectionScope; + if (!scope || editingCell) return; + table.querySelectorAll('td:not(.selected), th:not(.selected)').forEach(cell => { + const { row, col } = getCellCoords(cell); + if ('minRow' in scope && (row < scope.minRow || row > scope.maxRow)) return; + if ('minCol' in scope && (col < scope.minCol || col > scope.maxCol)) return; + cell.classList.add('selected'); + currentSelection.push(cell); + }); +}); + const extendSelectionTo = target => { rangeEndCell = target; const start = getCellCoords(anchorCell); @@ -2075,7 +2119,11 @@ document.addEventListener('paste', e => { }); }); -const selectAllCells = () => { clearSelection(); document.querySelectorAll('td, th').forEach(cell => { cell.classList.add('selected'); currentSelection.push(cell); }); }; +const selectAllCells = () => { + clearSelection(); + numericSelectionScope = { kind: 'all' }; + table.querySelectorAll('td, th').forEach(cell => { cell.classList.add('selected'); currentSelection.push(cell); }); +}; const setCursorToEnd = cell => { const range = document.createRange(); range.selectNodeContents(cell); range.collapse(false); @@ -2184,6 +2232,8 @@ const editCell = (cell, event, mode = 'detail') => { cell.classList.remove('selected'); originalCellValue = cell.textContent; editingCell = cell; + numericSelectionScope = null; + scheduleSelectionReport(); vscode.postMessage({ type: 'cellEditStarted' }); editMode = mode; cell.classList.add('editing'); @@ -2199,6 +2249,7 @@ const editCell = (cell, event, mode = 'detail') => { cell.removeAttribute('contenteditable'); cell.classList.remove('editing'); editingCell = null; + scheduleSelectionReport(); editMode = null; cell.removeEventListener('blur', onBlurHandler); }; diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 7fabbe3..640c9f4 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { parseClipboardMatrix, serializeClipboardMatrix } from './clipboard'; import { ColumnFilter, normalizeColumnFilters, compileColumnFilters, visibleSourceRows, remapColumnFilters, getFilterOperatorChoices, validateColumnFilterInput, MAX_COLUMN_FILTERS, MAX_FILTER_VALUE_LENGTH } from './column-filters'; import { FilterDataType, parseFilterDate } from './filter-values'; +import { NumericSelection, computeSelectionStatistics, formatSelectionStatistics, parseNumericSelection } from './selection-statistics'; type FrozenPanes = { rows: number; columns: number }; @@ -121,6 +122,11 @@ class CsvEditorController { private filterSessionUri = ''; private filterColumnsRevision = 0; private pendingFilterDraft: { uri: string; draft: FilterDraft } | undefined; + private numericSelection: NumericSelection | undefined; + private selectionStatusBar: vscode.StatusBarItem | undefined; + private selectionStatusText: string | undefined; + private selectionStatisticsVersion = -1; + private selectionData: { version: number; data: string[][] } | undefined; constructor(private readonly context: vscode.ExtensionContext) {} @@ -138,6 +144,9 @@ class CsvEditorController { // Register before remote stat/prompt awaits: a closed panel stays closed. webviewPanel.onDidDispose(() => { this.disposed = true; + this.selectionStatusBar?.dispose(); + this.selectionStatusBar = undefined; + this.selectionData = undefined; changeDocumentSubscription?.dispose(); saveDocumentSubscription?.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); @@ -183,6 +192,7 @@ class CsvEditorController { if (webviewPanel.active) { CsvEditorProvider.currentActive = this; this.updateWordWrapContext(); + this.updateSelectionStatusBar(); } void this.warnAboutPossibleDecoding().catch(error => { console.warn('CSV: Encoding warning action failed', error); @@ -203,10 +213,12 @@ class CsvEditorController { e.webviewPanel.webview.postMessage({ type: 'focus' }); CsvEditorProvider.currentActive = this; this.updateWordWrapContext(); + this.updateSelectionStatusBar(); } else if (CsvEditorProvider.currentActive === this) { CsvEditorProvider.currentActive = undefined; void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', false); } + if (!e.webviewPanel.active) { this.selectionStatusBar?.hide(); } }); webviewPanel.webview.onDidReceiveMessage(e => { @@ -240,6 +252,12 @@ class CsvEditorController { return; } switch (e.type) { + case 'selectionChanged': + if (e.session === this.filterSession && e.filterEpoch === this.filterEpoch) { + this.numericSelection = parseNumericSelection(e.selection); + this.refreshSelectionStatistics(true); + } + break; case 'webviewReady': { if (this.viewCommitRequested) { this.postViewCommitRequest(); @@ -388,7 +406,10 @@ class CsvEditorController { private enqueueOperation(operation: () => void | Promise): Promise { // Event listeners are not awaited by VS Code. Serialize mutations, reads and // saves so every command sees the result of the preceding document edit. - this.pendingOperation = this.pendingOperation.then(operation).catch(error => { + this.pendingOperation = this.pendingOperation.then(async () => { + await operation(); + this.refreshSelectionStatistics(); + }).catch(error => { console.error('CSV: Editor operation failed', error); }); return this.pendingOperation; @@ -955,6 +976,7 @@ class CsvEditorController { this.currentFilterView = view; this.currentHeader = view.headerEnabled; this.filterEpoch = rawRevision; + this.resetSelectionStatistics(data); reply({}); } @@ -1038,6 +1060,49 @@ class CsvEditorController { } } + private resetSelectionStatistics(data: string[][]): void { + this.selectionData = { version: this.document.version, data }; + this.numericSelection = undefined; + this.selectionStatusText = undefined; + this.selectionStatisticsVersion = -1; + this.selectionStatusBar?.hide(); + } + + private refreshSelectionStatistics(force = false): void { + if (this.disposed || (!force && this.selectionStatisticsVersion === this.document.version)) { return; } + this.selectionStatisticsVersion = this.document.version; + const view = this.currentFilterView; + if (!this.numericSelection || !view) { + this.selectionStatusText = undefined; + } else { + if (this.selectionData?.version !== this.document.version) { + this.selectionData = { + version: this.document.version, + data: this.trimTrailingEmptyRows(Papa.parse(this.document.getText(), { delimiter: this.getSeparator() }).data) + }; + } + const stats = computeSelectionStatistics(this.selectionData.data, this.numericSelection, + view.firstVisibleRow + (view.headerEnabled ? 1 : 0), view.filters.length ? view.visibleRowIds : undefined); + this.selectionStatusText = stats.count ? formatSelectionStatistics(stats) : undefined; + } + this.updateSelectionStatusBar(); + } + + private updateSelectionStatusBar(): void { + if (!this.isActive() || !this.selectionStatusText || this.disposed) { + this.selectionStatusBar?.hide(); + return; + } + if (!this.selectionStatusBar) { + this.selectionStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + this.selectionStatusBar.name = 'CSV Numeric Selection'; + this.selectionStatusBar.tooltip = 'Sum, average, and count of selected numeric data cells. Headers, hidden rows, blanks, and text are excluded.'; + this.context.subscriptions.push(this.selectionStatusBar); + } + this.selectionStatusBar.text = this.selectionStatusText; + this.selectionStatusBar.show(); + } + public setWordWrap(enabled: boolean): void { void this.currentWebviewPanel?.webview.postMessage({ type: 'setWordWrap', enabled }); } @@ -2181,6 +2246,7 @@ class CsvEditorController { this.filterEpoch = 0; this.filterSession = this.getNonce(); this.filterSessionUri = this.document.uri.toString(); + this.resetSelectionStatistics(data); const mouseWheelZoomEnabled = config.get('mouseWheelZoom', true); const mouseWheelZoomInvert = config.get('mouseWheelZoomInvert', false); const { tableHtml, chunksJson, colorCss, nextChunkStart, hasRemoteChunks } = view; diff --git a/src/selection-statistics.ts b/src/selection-statistics.ts new file mode 100644 index 0000000..2309b43 --- /dev/null +++ b/src/selection-statistics.ts @@ -0,0 +1,83 @@ +import { parseFilterNumber } from './filter-values'; + +export type NumericSelection = + | { kind: 'cells'; minRow: number; maxRow: number; minCol: number; maxCol: number } + | { kind: 'rows'; minRow: number; maxRow: number } + | { kind: 'columns'; minCol: number; maxCol: number } + | { kind: 'all' }; + +export interface SelectionStatistics { + sum: number; + average: number; + count: number; +} + +/** Validate the compact selection message before using its bounds. */ +export function parseNumericSelection(raw: unknown): NumericSelection | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return undefined; } + const value = raw as Record; + const validRange = (min: unknown, max: unknown): boolean => + typeof min === 'number' && typeof max === 'number' && Number.isSafeInteger(min) && Number.isSafeInteger(max) && min >= 0 && max >= min; + if (value.kind === 'all') { return { kind: 'all' }; } + if (value.kind === 'cells' || value.kind === 'rows') { + if (!validRange(value.minRow, value.maxRow)) { return undefined; } + if (value.kind === 'rows') { return { kind: 'rows', minRow: value.minRow as number, maxRow: value.maxRow as number }; } + } + if ((value.kind === 'cells' || value.kind === 'columns') && validRange(value.minCol, value.maxCol)) { + const columns = { minCol: value.minCol as number, maxCol: value.maxCol as number }; + return value.kind === 'columns' ? { kind: 'columns', ...columns } + : { kind: 'cells', minRow: value.minRow as number, maxRow: value.maxRow as number, ...columns }; + } + return undefined; +} + +/** Aggregate source values, including unloaded rows, within the displayed selection. */ +export function computeSelectionStatistics( + data: readonly (readonly string[])[], + selection: NumericSelection, + firstDataRow: number, + visibleRowIds?: readonly number[] +): SelectionStatistics { + const minRow = Math.max(firstDataRow, 'minRow' in selection ? selection.minRow : 0); + const maxRow = Math.min(data.length - 1, 'maxRow' in selection ? selection.maxRow : data.length - 1); + const minCol = 'minCol' in selection ? selection.minCol : 0; + const maxCol = 'maxCol' in selection ? selection.maxCol : Infinity; + let count = 0, sum = 0, correction = 0, scale = 0; + const addRow = (rowId: number) => { + if (rowId < minRow || rowId > maxRow) { return; } + const row = data[rowId]; + for (let col = minCol; col <= Math.min(maxCol, row.length - 1); col++) { + const value = parseFilterNumber(row[col]); + if (value === undefined) { continue; } + count++; + // Scale before compensated summation so intermediate totals cannot overflow. + // This also keeps the average finite when a valid sum exceeds Number.MAX_VALUE. + const magnitude = Math.abs(value); + if (magnitude > scale) { + const ratio = scale / magnitude; + sum *= ratio; + correction *= ratio; + scale = magnitude; + } + const normalized = scale ? value / scale : 0; + const next = sum + normalized; + correction += Math.abs(sum) >= Math.abs(normalized) ? (sum - next) + normalized : (normalized - next) + sum; + sum = next; + } + }; + if (visibleRowIds) { + for (const rowId of visibleRowIds) { addRow(rowId); } + } else { + for (let rowId = minRow; rowId <= maxRow; rowId++) { addRow(rowId); } + } + const total = sum + correction; + return { sum: total * scale, average: count ? (total / count) * scale : 0, count }; +} + +export function formatSelectionStatistics(stats: SelectionStatistics): string { + const format = (value: number) => { + if (!Number.isFinite(value)) { return 'Overflow'; } + return Number.isSafeInteger(value) ? String(value) : String(Number(value.toPrecision(15))); + }; + return `Sum: ${format(stats.sum)} Average: ${format(stats.average)} Count: ${stats.count}`; +} diff --git a/src/test/default-ignore-rows.test.ts b/src/test/default-ignore-rows.test.ts index 4a0da63..bfdf824 100644 --- a/src/test/default-ignore-rows.test.ts +++ b/src/test/default-ignore-rows.test.ts @@ -68,9 +68,9 @@ describe('Default hidden rows', () => { assert.strictEqual(setting.default, 0); assert.strictEqual(setting.minimum, 0); assert.strictEqual(setting.scope, 'resource'); - const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8'); - assert.ok(readme.includes('csv.defaultIgnoreRows')); - assert.ok(readme.includes('blank')); - assert.ok(readme.includes('explicit `0`')); + const guide = fs.readFileSync(path.join(process.cwd(), 'docs', 'user-guide.md'), 'utf8'); + assert.ok(guide.includes('csv.defaultIgnoreRows')); + assert.ok(guide.includes('blank')); + assert.ok(guide.includes('explicit `0`')); }); }); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index bcae523..13de15d 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -24,7 +24,15 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, + StatusBarAlignment: { Right: 2 }, window: { + createStatusBarItem: () => { + const item = { text: '', name: '', tooltip: '', visible: false, disposed: false, + show() { this.visible = true; }, hide() { this.visible = false; }, + dispose() { this.disposed = true; this.visible = false; } }; + activeHarness.statusBarItems.push(item); + return item; + }, activeColorTheme: { kind: 1 }, onDidChangeActiveTextEditor: () => ({ dispose() {} }), showTextDocument: async (document: any) => { @@ -255,6 +263,7 @@ export async function createEditorHarness(options: { commands: new Map unknown>(), executedCommands: [] as Array<{ name: string; args: unknown[] }>, contextUpdates: [] as unknown[][], + statusBarItems: [] as Array<{ text: string; name: string; tooltip: string; visible: boolean; disposed: boolean }>, registerCommands() { const { registerCsvCommands } = require('../commands') as typeof import('../commands'); registerCsvCommands(context as any); diff --git a/src/test/editor-switch.test.ts b/src/test/editor-switch.test.ts index 604113c..6971716 100644 --- a/src/test/editor-switch.test.ts +++ b/src/test/editor-switch.test.ts @@ -302,9 +302,9 @@ describe('single-view CSV table/text switching', () => { assert.ok(action.when.includes('!isInDiffEditor')); assert.ok(action.when.includes('resourceScheme != untitled')); assert.ok(!action.when.includes('workbench.editors.textEditor')); - const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); - assert.ok(!readme.includes('Both representations may remain open')); - assert.ok(readme.includes('one representation remains')); + const guide = fs.readFileSync(path.join(root, 'docs', 'user-guide.md'), 'utf8'); + assert.ok(!guide.includes('Both representations may remain open')); + assert.ok(guide.includes('one representation remains')); }); }); diff --git a/src/test/frozen-panes.test.ts b/src/test/frozen-panes.test.ts index bc82ca0..81e93f3 100644 --- a/src/test/frozen-panes.test.ts +++ b/src/test/frozen-panes.test.ts @@ -340,11 +340,11 @@ describe('Frozen panes', () => { it('adds documented command contributions and opaque sticky theme surfaces', () => { const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); - const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8'); + const guide = fs.readFileSync(path.join(process.cwd(), 'docs', 'user-guide.md'), 'utf8'); for (const command of ['csv.changeFrozenRows', 'csv.changeFrozenColumns']) { assert.ok(manifest.contributes.commands.some((item: any) => item.command === command)); assert.ok(manifest.activationEvents.includes(`onCommand:${command}`)); - assert.ok(readme.includes(command)); + assert.ok(guide.includes(command)); } const provider = fs.readFileSync(path.join(process.cwd(), 'src', 'CsvEditorProvider.ts'), 'utf8'); assert.ok(provider.includes('background-color: var(--vscode-editor-background,')); diff --git a/src/test/selection-statistics.test.ts b/src/test/selection-statistics.test.ts new file mode 100644 index 0000000..39fc131 --- /dev/null +++ b/src/test/selection-statistics.test.ts @@ -0,0 +1,50 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { computeSelectionStatistics, formatSelectionStatistics, parseNumericSelection } from '../selection-statistics'; + +describe('Numeric selection statistics', () => { + it('counts finite numeric values, including zero, independently of column type', () => { + const data = [['0', ' -2.5 ', '.5', '1e2', '1,000', "2'000", '3\u202f000', '4 000'], + ['', ' ', 'text', '12px', '$20', '20%', 'NaN', 'Infinity', '0x10', '12,34', '1e309']]; + const stats = computeSelectionStatistics(data, { kind: 'all' }, 0); + assert.strictEqual(formatSelectionStatistics(stats), 'Sum: 10098 Average: 1262.25 Count: 8'); + }); + + it('respects row/column bounds, hidden metadata, numeric headers, filters, and ragged rows', () => { + const data = [['999'], ['2026', '99'], ['2', '3'], ['100', '200'], ['4'], ['6', '7']]; + const visibleRows = [1, 2, 4, 5, 6]; + assert.strictEqual(formatSelectionStatistics(computeSelectionStatistics(data, { kind: 'all' }, 2, visibleRows)), + 'Sum: 22 Average: 4.4 Count: 5'); + assert.strictEqual(formatSelectionStatistics(computeSelectionStatistics(data, { kind: 'columns', minCol: 1, maxCol: 1 }, 2, visibleRows)), + 'Sum: 10 Average: 5 Count: 2'); + assert.strictEqual(formatSelectionStatistics(computeSelectionStatistics(data, { kind: 'rows', minRow: 2, maxRow: 4 }, 2, visibleRows)), + 'Sum: 9 Average: 3 Count: 3'); + assert.strictEqual(formatSelectionStatistics(computeSelectionStatistics(data, { kind: 'cells', minRow: 2, maxRow: 4, minCol: 0, maxCol: 0 }, 2, visibleRows)), + 'Sum: 6 Average: 3 Count: 2'); + assert.deepStrictEqual(computeSelectionStatistics(data, { kind: 'rows', minRow: 6, maxRow: Number.MAX_SAFE_INTEGER }, 2), + { sum: 0, average: 0, count: 0 }); + }); + + it('formats decimal sums cleanly and preserves cancellation and very small values', () => { + const stats = (values: string[]) => computeSelectionStatistics([values], { kind: 'all' }, 0); + assert.strictEqual(formatSelectionStatistics(stats(['0.1', '0.2'])), 'Sum: 0.3 Average: 0.15 Count: 2'); + assert.strictEqual(stats(['1e16', '1', '-1e16']).sum, 1); + assert.strictEqual(stats(['1e-20']).average, 1e-20); + assert.strictEqual(formatSelectionStatistics(stats(['9007199254740991'])), + 'Sum: 9007199254740991 Average: 9007199254740991 Count: 1'); + assert.strictEqual(formatSelectionStatistics(stats(['-0', '0'])), 'Sum: 0 Average: 0 Count: 2'); + assert.strictEqual(formatSelectionStatistics(stats(['1e308', '1e308'])), 'Sum: Overflow Average: 1e+308 Count: 2'); + assert.strictEqual(stats(['1e308', '1e308', '-1e308']).sum, 1e308); + }); + + it('rejects malformed selection messages and clamps valid large bounds to the data', () => { + for (const raw of [null, [], 'all', { kind: 'unknown' }, { kind: 'rows', minRow: 2, maxRow: 1 }, + { kind: 'cells', minCol: 0, maxCol: 1 }, { kind: 'columns', minCol: -1, maxCol: 0 }, + { kind: 'columns', minCol: 0, maxCol: Infinity }, { kind: 'rows', minRow: '0', maxRow: 1 }]) { + assert.strictEqual(parseNumericSelection(raw), undefined); + } + const selection = parseNumericSelection({ kind: 'cells', minRow: 0, maxRow: Number.MAX_SAFE_INTEGER, minCol: 0, maxCol: Number.MAX_SAFE_INTEGER }); + assert.ok(selection); + assert.deepStrictEqual(computeSelectionStatistics([['2']], selection!, 0), { sum: 2, average: 2, count: 1 }); + }); +}); diff --git a/src/test/webview-navigation-scroll.test.ts b/src/test/webview-navigation-scroll.test.ts index a2d844d..ee12778 100644 --- a/src/test/webview-navigation-scroll.test.ts +++ b/src/test/webview-navigation-scroll.test.ts @@ -128,7 +128,7 @@ async function fixture(options: { separator?: string; rowCount?: number; columnC assert.ok(rect.left >= viewport.left + leftInset && rect.right <= viewport.right, `column ${target.getAttribute('data-col')} must remain in the usable viewport`); }; return { host, window, container, table, messages, scrollCalls, focusCalls, cell, select, key, flush, finishSmooth, visible, - get state() { return state; }, get pendingSmooth() { return pendingSmooth; }, get frameCount() { return frames.size; }, + get state() { return state; }, get pendingSmooth() { return pendingSmooth; }, anchor: () => window.navigationTestApi.anchor(), endpoint: () => window.navigationTestApi.endpoint(), close: () => window.close() }; } @@ -157,11 +157,16 @@ describe('Keyboard navigation viewport reveal', () => { const view = await fixture(); try { view.select(1, 0); + view.messages.length = 0; for (let column = 1; column <= 10; column++) { view.key('ArrowRight', { repeat: true }); } assert.strictEqual(view.window.document.activeElement, view.cell(1, 10)); - assert.strictEqual(view.frameCount, 1, 'one pending reveal for the newest target'); + assert.strictEqual(view.scrollCalls.length, 0, 'reveals wait for the next paint'); view.flush(); - assert.strictEqual(view.scrollCalls.length, 1); + assert.strictEqual(view.scrollCalls.length, 1, 'one reveal for the newest target'); + const selections = view.messages.filter(message => message.type === 'selectionChanged'); + assert.strictEqual(selections.length, 1, 'selection reporting also coalesces the burst'); + assert.strictEqual(selections[0].selection.minCol, 10); + assert.strictEqual(selections[0].selection.maxCol, 10); view.visible(view.cell(1, 10)); for (let column = 9; column >= 1; column--) { view.key('ArrowLeft', { repeat: true }); diff --git a/src/test/webview-selection-statistics.test.ts b/src/test/webview-selection-statistics.test.ts new file mode 100644 index 0000000..ebbe920 --- /dev/null +++ b/src/test/webview-selection-statistics.test.ts @@ -0,0 +1,185 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +type Host = Awaited>; +type View = ReturnType; +const paint = (view: View) => new Promise(resolve => view.window.requestAnimationFrame(() => resolve())); +const status = (host: Host) => host.statusBarItems.find(item => item.visible)?.text; +const deliver = async (host: Host, view: View) => { + await paint(view); + for (const message of view.messages.splice(0)) { await host.receive(message); } +}; +const click = (view: View, row: number, col: number, options: Record = {}) => { + const cell = view.cell(row, col); + for (const type of ['mousedown', 'mouseup', 'click']) { + cell.dispatchEvent(new view.window.MouseEvent(type, { button: 0, detail: 1, bubbles: true, cancelable: true, clientX: 60, clientY: 45, ...options })); + } +}; + +describe('Selection statistics in the native status bar', () => { + for (const separator of [',', '\t']) { + it(`updates mouse, keyboard, row and rectangular selections (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ separator, rows: [['2026', 'Value'], ['2', 'text'], ['4', '6'], ['', '0']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + await deliver(host, view); + assert.strictEqual(status(host), undefined); + click(view, 1, 0); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 2 Average: 2 Count: 1'); + click(view, 2, 1, { shiftKey: true }); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 12 Average: 4 Count: 3'); + click(view, 2, -1); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 10 Average: 5 Count: 2'); + click(view, 1, 0); + view.key('ArrowDown', { shiftKey: true }); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 6 Average: 3 Count: 2'); + view.key('a', { ctrlKey: true }); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 12 Average: 3 Count: 4'); + view.key('Escape'); + await deliver(host, view); + assert.strictEqual(status(host), undefined); + click(view, 1, 1); + await deliver(host, view); + assert.strictEqual(status(host), undefined, 'text does not count'); + click(view, 4, 0); + await deliver(host, view); + assert.strictEqual(status(host), undefined, 'the append row does not count'); + click(view, 0, 0); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 6 Average: 3 Count: 2'); + click(view, 0, 0); + await deliver(host, view); + assert.strictEqual(status(host), undefined, 'a numeric header selected on its own does not count'); + } finally { view.close(); host.dispose(); } + }); + } + + it('ignores hidden rows and restores a numeric selection with headers and serial index off', async () => { + const host = await createEditorHarness({ rows: [['100'], ['2'], ['4'], ['6']], header: false, serialIndex: false, hiddenRows: 1, + filters: [{ column: 0, operator: 'notEquals', value: '4', dataType: 'number' }] }); + const view = createWebviewDom(host.panel.webview.html, { anchorRow: 1, anchorCol: 0 }); + try { + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 2 Average: 2 Count: 1'); + click(view, 3, 0, { shiftKey: true }); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 8 Average: 4 Count: 2'); + view.key('a', { ctrlKey: true }); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 8 Average: 4 Count: 2'); + } finally { view.close(); host.dispose(); } + }); + + it('includes remote chunks in column/table totals without loading them into the webview', async () => { + const host = await createEditorHarness({ rows: [['Value'], ...Array.from({ length: 1200 }, () => ['2'])] }); + const view = createWebviewDom(host.panel.webview.html); + try { + await deliver(host, view); + assert.strictEqual(view.cell(1200, 0), null); + click(view, 0, 0); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 2400 Average: 2 Count: 1200'); + view.key('a', { ctrlKey: true }); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 2400 Average: 2 Count: 1200'); + assert.strictEqual(view.cell(1200, 0), null); + view.window.__csvLoadNextChunk(); + await deliver(host, view); + const chunk = host.messages.find(message => message.type === 'chunkData'); + assert.ok(chunk); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: chunk })); + assert.ok(view.cell(1200, 0).classList.contains('selected'), 'later chunks inherit the selection represented by the totals'); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 2400 Average: 2 Count: 1200'); + } finally { view.close(); host.dispose(); } + }); + + it('updates committed edits and hides while editing, inactive, or disposed', async () => { + const host = await createEditorHarness({ rows: [['Value'], ['2'], ['4']], untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + await deliver(host, view); + click(view, 0, 0); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 6 Average: 3 Count: 2'); + await host.receive({ type: 'editCell', row: 1, col: 0, value: '10' }); + assert.strictEqual(status(host), 'Sum: 14 Average: 7 Count: 2'); + await host.view(false, true); + assert.strictEqual(status(host), undefined); + await host.view(true, true); + assert.strictEqual(status(host), 'Sum: 14 Average: 7 Count: 2'); + view.start(view.cell(1, 0)); + await deliver(host, view); + assert.strictEqual(status(host), undefined, 'uncommitted cell drafts are not selection totals'); + view.cell(1, 0).textContent = '8'; + view.key('Tab', { shiftKey: true }); + await deliver(host, view); + click(view, 0, 0); + await deliver(host, view); + assert.strictEqual(status(host), 'Sum: 12 Average: 6 Count: 2'); + host.dispose(); + assert.strictEqual(status(host), undefined); + assert.ok(host.statusBarItems.every(item => item.disposed)); + } finally { view.close(); if (!host.panel.disposed) { host.dispose(); } } + }); + + it('rejects stale messages after filter changes and full refreshes', async () => { + const host = await createEditorHarness({ rows: [['Value'], ['2'], ['4']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + await deliver(host, view); + click(view, 0, 0); + await paint(view); + const selected = view.messages.find(message => message.type === 'selectionChanged'); + assert.ok(selected); + await deliver(host, view); + await host.receive({ type: 'updateColumnFilters', session: selected.session, revision: 1, + filters: [{ column: 0, operator: 'equals', value: '2' }] }); + assert.strictEqual(status(host), undefined); + await host.receive(selected); + assert.strictEqual(status(host), undefined, 'the old filter epoch cannot restore stale totals'); + await host.receive({ ...selected, filterEpoch: 1 }); + assert.strictEqual(status(host), 'Sum: 2 Average: 2 Count: 1'); + await host.view(false, false); + await host.view(true, true); + assert.strictEqual(status(host), undefined); + await host.receive({ ...selected, filterEpoch: 1 }); + assert.strictEqual(status(host), undefined, 'the old webview session cannot restore stale totals'); + } finally { view.close(); host.dispose(); } + }); + + it('keeps status values owned by the active editor when a background editor reports a selection', async () => { + const first = await createEditorHarness({ rows: [['Value'], ['2']], fileName: '/test/first.csv' }); + const firstView = createWebviewDom(first.panel.webview.html); + let second: Host | undefined; + let secondView: View | undefined; + try { + await deliver(first, firstView); + click(firstView, 1, 0); + await deliver(first, firstView); + assert.strictEqual(status(first), 'Sum: 2 Average: 2 Count: 1'); + await first.view(false, true); + second = await createEditorHarness({ rows: [['Value'], ['10']], fileName: '/test/second.csv' }); + secondView = createWebviewDom(second.panel.webview.html); + await deliver(second, secondView); + click(secondView, 1, 0); + await deliver(second, secondView); + assert.strictEqual(status(first), undefined); + assert.strictEqual(status(second), 'Sum: 10 Average: 10 Count: 1'); + click(firstView, 0, 0); + await deliver(first, firstView); + assert.strictEqual(status(first), undefined); + assert.strictEqual(status(second), 'Sum: 10 Average: 10 Count: 1'); + await second.view(false, true); + await first.view(true, true); + assert.strictEqual(status(first), 'Sum: 2 Average: 2 Count: 1'); + assert.strictEqual(status(second), undefined); + } finally { firstView.close(); first.dispose(); secondView?.close(); second?.dispose(); } + }); +});