diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9eb0f53..c73e56b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,16 +4,15 @@ on:
push:
branches: [ main, work ]
pull_request:
- branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
with:
- node-version: 18
+ node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test
diff --git a/.vscodeignore b/.vscodeignore
index 7d3e5c7..4ac8a1a 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -9,3 +9,13 @@ vsc-extension-quickstart.md
**/*.map
**/*.ts
**/.vscode-test.*
+
+dist/**
+out/test/**
+.github/**
+.agents/**
+.codex/**
+.mcp.json
+AGENTS.md
+scripts/**
+images/*-*.jpg
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09902bc..e1f9293 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,32 @@
# Changelog
-All notable changes to the "CSV" extension will be documented in this file.
+## [1.4.2] - 2026-10-17
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+This release combines the editing, navigation, and table tools developed since the public 1.3 release.
+
+### Editing and file integrity
+
+- Preserve multiline drafts through repeated line breaks, blur, saves, tab changes, and table/text switches. Failed edits remain available for retry or explicit discard.
+- Preserve existing CSV quoting and line endings during cell edits, structural paste, and row/column moves. Copy and Cut quote each value only when necessary, retain real empty fields, and exclude the extra append row.
+- Add saved-file table/text replacement with unsaved-edit preservation and explicit Save As support for untitled CSV/TSV tables that retain their native owner.
+- Keep native Find in Files destinations in text after an explicit local-file Text choice, including restart and Off/On. Restore only owned routing preferences on return to Table and preserve intervening user choices.
+- Preserve the active document through multi-file Off/On changes. Serialize native handoffs and request a fresh acknowledgment for cell or filter activity made while waiting.
+- Improve encoding guidance without guessing or silently changing the source encoding.
+
+### Table navigation and views
+
+- Add contextual first-row header and serial-index actions, frozen leading rows/columns, remembered manual widths, and a configurable default for hidden leading metadata.
+- Fix column resize hit testing at fractional borders and frozen seams, retain usable narrow-column centers, and make dragged widths follow the pointer. Automatic sizing accounts for multiline values.
+- Show selection immediately on mouse-down. Support a second separate header click for header-only selection, anchored Ctrl/Cmd+Shift+Arrow ranges, and viewport following during held-arrow navigation.
+- Keep modified native Find/Replace shortcuts outside the table overlay. Position context menus using available viewport space, with scrolling in small panes.
+- Paint frozen-row divider seams without changing row geometry. Preserve the chosen datatype palette or theme foreground mode across light, dark, and high-contrast themes.
+
+### Filtering, preview, and comparison
+
+- Add inline live column filters with multiple AND conditions, including repeated conditions on one column; inferred number/date/boolean comparisons; Does Not Contain; and RE2 regular expressions. Preserve input focus, invalid drafts, source row identities, and the full source when saving.
+- Add a bounded, paginated read-only preview for large saved UTF-8 files. The direct Explorer/command path avoids opening a full text document first; malformed or oversized records produce errors instead of truncation.
+- Add explicit read-only table comparison with record alignment, changed-cell highlighting, synchronized panes, change navigation, and bounded display/alignment work. Native Git/PR custom-editor diff remains a separate limitation.
+- Document privacy and data handling, including remote extension hosts, clipboard/link actions, diagnostic logs, and saved filter values.
## [1.2.1] - 2025-08-20
- Fixed: Scrolling freeze at 1000 rows when header was enabled by making chunking consistent across modes and using a safe JSON transport to the webview.
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..bc2194b
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,33 @@
+# Privacy and data handling
+
+CSV does not implement an upload service, analytics client, or extension-specific telemetry collection. Normal editable views parse, search, sort, and edit files through the VS Code document and the extension's bundled code. No account or external processing service is required by CSV.
+
+## Where processing happens
+
+Normal editable CSV views receive the file's decoded text from VS Code. The extension parses that text with the bundled Papa Parse library, sends table content to its VS Code webview, and applies edits through VS Code's document APIs. Saving in these views uses VS Code's normal file-saving behavior and the selected file encoding.
+
+The read-only large-file preview supports host-local UTF-8 files and reads bounded blocks directly from the saved file. It does not write the saved file.
+
+For a normal local workspace, these operations run on your computer. In Remote SSH, WSL, Dev Containers, or another remote workspace, the extension host and files can be on that remote environment. File contents travel through your existing VS Code connection to display and edit them in the webview. “No external processing service” does not mean that a remote workspace's data stays on your local computer.
+
+The font picker uses the bundled `font-list` package to enumerate fonts available to the extension host through operating-system helpers. It does not use an online font service.
+
+## Actions that share information outside the table
+
+- **Clipboard:** Copying writes the selected contents to the system clipboard; pasting reads clipboard contents supplied by the editor. Other applications, clipboard managers, and operating-system clipboard synchronization may have access to that clipboard.
+- **Links:** Ctrl/Cmd+click on a recognized link asks VS Code to open its URL using your configured browser or application. Supported schemes include HTTP, HTTPS, FTP, and email links. The destination receives the URL, including any information in its path or query, according to that application's behavior. CSV does not fetch a link merely to render its text. You can disable link interaction with `csv.clickableLinks`.
+- **Support:** If you attach a file, screenshot, or log to a GitHub issue, you are sharing it with that issue's audience. CSV does not submit these materials automatically.
+
+VS Code's Marketplace connections, updates, Settings Sync, remote services, other extensions, and the browser or application used for links have their own data-handling behavior. They are separate from CSV's runtime processing.
+
+## Logs and stored state
+
+The extension writes diagnostic messages to the extension host's console. Routine messages include file paths/URIs, row counts, and the row/column coordinates of an edit; routine edit messages omit the cell value. Errors can still contain document or operation details, and a failed external-link operation can log its URL. Treat extension host logs as potentially containing sensitive information, especially before sharing them for support. The log location and retention are controlled by VS Code and may be in a remote environment.
+
+CSV's code does not send those logs or cell contents to a telemetry endpoint. This statement does not describe or override VS Code's own telemetry, logging, crash reporting, or another extension's behavior. Consult your editor's privacy policy and settings for those services.
+
+CSV stores per-file view preferences in VS Code workspace state on the extension host, keyed by the file URI: header mode, serial-index visibility, separator overrides, hidden leading rows, manual column widths, frozen rows/columns, and column filters. Valid filter conditions are saved as you type in the filter details row and may contain sensitive CSV values; incomplete or invalid drafts are not saved. Clear all filters removes the saved conditions for that file. Its webview state remembers scroll/selection positions, column widths, row heights, and zoom. View preferences and saved state can themselves contain sensitive data and metadata; they are not intended as full-document backups. VS Code independently manages the document, undo history, unsaved-file backups, autosave, and any platform synchronization you enable.
+
+## Scope of these statements
+
+These statements describe this repository's extension and webview implementation. The main data paths are in [`src/CsvEditorProvider.ts`](src/CsvEditorProvider.ts), [`media/main.js`](media/main.js), [`src/commands.ts`](src/commands.ts), and [`src/extension.ts`](src/extension.ts); runtime dependencies are declared in [`package.json`](package.json). They are not a promise about every VS Code component, remote host, dependency version, or installed extension. Review the version you use and your environment's policies when deciding whether it fits your data requirements.
diff --git a/README.md b/README.md
index 7dec9d6..e7c9c10 100644
--- a/README.md
+++ b/README.md
@@ -1,203 +1,193 @@
-# **CSV**
+# CSV
-Experience a whole new way to work with CSV files right inside VS Code. CSV transforms your CSV files into an interactive, spreadsheet-like experience—making it effortless to view, edit, and navigate your data with precision and speed.
+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.
----
+Actual CSV table webview with example data, captured in Chromium.
-## Screenshots
+
+
-
-
+## 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`.
-## Why 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.
-Working with CSV files shouldn’t be a chore. With CSV, you get:
+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.
-- **Direct In-Place Editing:** Click on any cell to edit its content seamlessly. Your changes can be saved immediately to the CSV file, ensuring data integrity without extra steps.
-- **Smart Column Sizing & Dynamic Color Coding:** Columns automatically adjust to fit content while being visually distinguished by data type. Whether it’s boolean, date, integer, float, or text, each column gets its own adaptive color that adjusts for light and dark themes.
-- **Sticky Headers & Fluid Navigation:** Keep your header row always visible as you scroll. Effortlessly move through cells using intuitive keyboard shortcuts like `Tab`, `Shift + Tab`, and arrow keys—just like a full-featured spreadsheet.
-- **Efficient Multi-Cell Selection & Clipboard Integration:** Select a range of cells with click-and-drag and copy them as well-formatted CSV data using `Ctrl/Cmd + C`.
-- **Grid Paste Support:** Paste copied ranges directly into the selected cell/range with `Ctrl/Cmd + V` in selection mode.
-- **Robust Data Handling:** Leveraging the power of [Papa Parse](https://www.papaparse.com/), the extension handles complex CSV structures, special characters, and various data types gracefully.
-- **Theme-Optimized Interface:** Whether you prefer light or dark mode, CSV automatically adapts its styles for an optimal viewing experience.
+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.
-## Features
+## Select, copy, paste, and arrange data
-- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Saves on blur.
-- **Smart Resizing:** Automatic calculation of column widths for improved readability.
-- **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings).
-- **Sticky Headers:** Keep column titles in view as you scroll through large datasets.
-- **Enhanced Keyboard Navigation:** Navigate cells with arrows and Tab/Shift+Tab; quick edits can commit with arrow keys; `Ctrl/Cmd + A` selects all; `Ctrl/Cmd + C` copies selection.
-- **Advanced Multi-Cell Selection:** Easily select and copy blocks of data, then paste them elsewhere as properly formatted CSV.
-- **Add/Delete Columns:** Right-click any cell to add a column left or right, or remove the selected column.
-- **Add/Delete Rows:** Insert above/below or remove the selected row via context menu.
-- **Edit Empty CSVs:** Create or open an empty CSV file and start typing immediately.
-- **Column Sorting:** Right-click a header and choose A–Z or Z–A.
-- **Custom Font Controls:** Choose a font family and optional font-size override, or inherit VS Code defaults.
-- **In-View Zoom:** Use `Ctrl/Cmd + Mouse Wheel` or `Ctrl/Cmd + +/-/0` to zoom the CSV view without changing global editor zoom.
-- **Find & Replace Overlay:** Built-in find/replace bar with match options (case, whole-word, regex), keyboard navigation, and single/all replace actions across the full file (including chunked rows).
-- **Multiline Cell Display:** Cells with embedded newlines render as wrapped multi-line content (with preserved line breaks and matching row height).
-- **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser.
-- **Preserved CSV Integrity:** All modifications respect CSV formatting—no unwanted extra characters or formatting issues.
-- **Optimized for Performance:** Designed for medium-sized datasets, ensuring a smooth editing experience without compromising on functionality.
-- **Large File Support:** Loads big CSVs in chunks so even large datasets open quickly.
-- **CSV/TSV/TAB/PSV Support:** `.csv`, `.tsv`, `.tab`, and `.psv` files are recognized automatically. Defaults: comma for CSV, tab for TSV/TAB, pipe for PSV.
+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.
-## Compatibility
+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.
-This extension is built for VS Code **1.70.0** and later. It has been tested with
-Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102).
+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.
-## Getting Started
+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.
-### 1. Install the Extension
+## Headers, metadata rows, widths, and frozen panes
-- Open Visual Studio Code.
-- Go to the Extensions view (`Ctrl+Shift+X` or `Cmd+Shift+X` on macOS).
-- Search for **CSV** and click **Install**.
+- **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.
+- **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.
-### 2. Open a CSV, TSV, TAB, or PSV File
+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.
-- Open any `.csv`, `.tsv`, `.tab`, or `.psv` file in VS Code.
-- The file will automatically load, presenting your data in an interactive grid view.
+## Live column filters
-### 3. Edit and Navigate
+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.
-- **Edit Modes:**
- - Quick edit: start typing any character to edit the selected cell immediately. Press any Arrow key to save and move the selection to the next cell in that direction.
- - Detail edit: press `Enter` on a selected cell or double‑click to enter a focused edit. Arrow Left/Right move the text caret; Arrow Up goes to start; Arrow Down goes to end. Click outside the cell (or blur) to save.
-- **Keyboard Navigation:** Use Arrow keys to move between cells when not editing. Use `Tab`/`Shift+Tab` to move horizontally (wrapping across rows as needed).
-- **Multi-Cell Selection:** Click and drag or use `Shift + Click` to select multiple cells, then copy them as CSV using `Ctrl/Cmd + C`.
-- **Find & Replace:** Press `Ctrl/Cmd + F` to open Find, or `Ctrl/Cmd + H` to open Find + Replace.
+
----
+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.
-## Commands
+- 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.
-Open the Command Palette and search for:
+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.
-- `CSV: Toggle Extension On/Off` (`csv.toggleExtension`)
-- `CSV: Toggle First Row as Header` (`csv.toggleHeader`)
-- `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`)
-- `CSV: Change CSV Separator` (`csv.changeSeparator`)
-- `CSV: Reset CSV Separator (Inherit)` (`csv.resetSeparator`)
-- `CSV: Change Font Family` (`csv.changeFontFamily`)
-- `CSV: Hide First N Rows` (`csv.changeIgnoreRows`)
-- `CSV: Change File Encoding` (`csv.changeEncoding`)
-- `CSV: Toggle Clickable Links` (`csv.toggleClickableLinks`)
-
+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.
-## Settings
+## 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.
-Global (Settings UI or `settings.json`):
-
-- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor.
-- `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`.
-- `csv.fontSize` (number, default `0`): Override font size in px; set to `0` to inherit `editor.fontSize`.
-- `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor.
-- `csv.mouseWheelZoomInvert` (boolean, default `false`): Invert the `Ctrl/Cmd + Mouse Wheel` zoom direction.
-- `csv.cellPadding` (number, default `4`): Vertical cell padding in pixels.
-- `csv.columnColorMode` (string, default `type`): `type` keeps CSV’s type-based column colors; `theme` uses your theme foreground color for all columns.
-- `csv.columnColorPalette` (string, default `default`): Type-color palette when `csv.columnColorMode` is `type`. `cool` biases colors toward greens/blues; `warm` biases colors toward oranges/reds.
-- `csv.diffUseThemeForeground` (boolean, default `true`): In compare/diff views, use theme foreground color so diff highlighting remains readable.
-- `csv.clickableLinks` (boolean, default `true`): Make URLs in cells clickable. Ctrl/Cmd+click to open links.
-- `csv.showTrailingEmptyRow` (boolean, default `true`): Show the extra empty row at the end of the table. Turn this off to hide that visual append row.
-- `csv.separatorMode` (string, default `extension`): Separator selection mode when no per-file override exists. `extension` uses extension mapping, `auto` detects from content first, `default` always uses `csv.defaultSeparator`.
-- `csv.defaultSeparator` (string, default `,`): Fallback separator. Use `\\t` in `settings.json` for tabs.
-- `csv.separatorByExtension` (object): Extension-to-separator mapping (defaults include `.csv`→`,`, `.tsv`/`.tab`→tab, `.psv`→`|`).
-- `csv.maxFileSizeMB` (number, default `10`): Soft limit for opening files in CSV view. If exceeded, CSV prompts: `Cancel`, `Continue This Time`, or `Ignore Forever` (sets this setting to `0`).
-- Per-file encoding: use `CSV: Change File Encoding` to set a file's encoding (e.g., `utf8`, `utf16le`, `windows1250`, `gbk`). The extension will reopen the file using the chosen encoding.
+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.
-Per-file (stored by the extension; set via commands):
+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.
-- First row as header (default `true`) — `CSV: Toggle First Row as Header`
-- Serial index column (default `true`) — `CSV: Toggle Serial Index Column`
-- CSV separator override — `CSV: Change CSV Separator` (or clear it with `CSV: Reset CSV Separator (Inherit)`)
-- Hide first N rows (default `0`) — `CSV: Hide First N Rows`
+**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
-## Editing Modes and Shortcuts
+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.
-- Quick edit:
- - Start: type any character (not Enter) on a selected cell.
- - Save and move: press Arrow Up/Down/Left/Right to save and select the adjacent cell; does not re-enter edit.
-- Detail edit:
- - Start: press `Enter` on a selected cell or double‑click a cell.
- - Caret navigation: Arrow Left/Right move one character; Arrow Up moves caret to start; Arrow Down moves caret to end.
- - New line in cell: `Shift + Enter` inserts a line break inside the current cell.
- - Exit/save: click outside the cell or move focus to commit changes.
-- Global:
- - Copy selection: `Ctrl/Cmd + C`
- - Paste selection: `Ctrl/Cmd + V` (selection mode). Pasting a single value into a selected rectangle fills that rectangle.
- - Zoom in/out/reset: `Ctrl/Cmd + +`, `Ctrl/Cmd + -`, `Ctrl/Cmd + 0` (also `Ctrl/Cmd + Mouse Wheel`)
- - Find: `Ctrl/Cmd + F`
- - Replace: `Ctrl/Cmd + H`
- - Next/Previous match: `F3` / `Shift + F3` (also `Enter` / `Shift + Enter` in the Find box)
- - Select all: `Ctrl/Cmd + A`
+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.
-## Release Notes
+## Large File Preview
-### v1.2.1
-- Fix scrolling freeze at ~1000 rows when header is enabled by unifying chunking behavior and safely transporting chunks to the webview.
-- Trim trailing empty lines to avoid phantom last rows; correct final virtual row numbering.
-- Preserve scroll position after edits/saves in later chunks by loading enough chunks before restoring scroll.
-- Add tests for separators (CSV/TSV overrides), strict date parsing, header heuristics, and chunking stability.
+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.
-### v1.2.0
-- Edit modes: Quick edit (type to start; arrows save and move) and Detail edit (Enter/double‑click; arrows move caret, Up/Down jump to start/end).
-- Virtual rows and cells: Always shows one extra empty row at the bottom; short rows display empty editable cells up to the widest column. Empty edits do not create real rows/columns.
-- State persistence: Keeps scroll position and selection across tab switches and configuration refreshes, including large files with chunked rendering.
-- Selection improvements: Shift+Click on headers selects column ranges; Shift+Click on the serial index selects row ranges; right‑click preserves current selection.
-- Batch actions: Context menu adapts to multi‑selection (Add/Delete X Rows/Columns) and performs exact counts in a single operation.
-- Delete to clear: Press Delete/Backspace to clear contents of selected cells (skips serial index column).
-- Copy fidelity: Copies with the active delimiter and skips the serial index column for full‑row copies.
-- Encoding: New command “CSV: Change File Encoding” integrates VS Code’s encoding picker and returns to the CSV view.
-- Enable/disable UX: Toggling the extension on instantly upgrades open CSV/TSV tabs to this view; toggling off reverts immediately to the default view.
+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.
-See full history in `CHANGELOG.md`.
+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**.
-## Development
+## Compare two files as tables
-Clone the repository and run the following commands:
+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.
-```bash
-npm install
-npm run lint
-npm test
-```
+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.
-To create a VS Code extension package, run:
+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.
-```bash
-npm run package
-```
+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.
-To compile without running tests:
+## File encoding
-```bash
-npm run compile
-```
+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.
-## Support
+## 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: 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
-Have questions, suggestions, or encountered an issue?
-- Open an issue on [GitHub](https://github.com/jonaraphael/csv/issues) and let us know how we can help!
-
----
-
-## License
-
-This extension is licensed under the [MIT License](LICENSE).
+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/context-menu-edge.jpg b/images/context-menu-edge.jpg
new file mode 100644
index 0000000..3e59353
Binary files /dev/null and b/images/context-menu-edge.jpg differ
diff --git a/images/frozen-row-dividers.jpg b/images/frozen-row-dividers.jpg
new file mode 100644
index 0000000..7ee7641
Binary files /dev/null and b/images/frozen-row-dividers.jpg differ
diff --git a/images/frozen-top-edge.jpg b/images/frozen-top-edge.jpg
new file mode 100644
index 0000000..d0ef25d
Binary files /dev/null and b/images/frozen-top-edge.jpg differ
diff --git a/images/header-menu-later.jpg b/images/header-menu-later.jpg
new file mode 100644
index 0000000..b4990f4
Binary files /dev/null and b/images/header-menu-later.jpg differ
diff --git a/images/header-menu-off.jpg b/images/header-menu-off.jpg
new file mode 100644
index 0000000..b9b2890
Binary files /dev/null and b/images/header-menu-off.jpg differ
diff --git a/images/header-menu-on.jpg b/images/header-menu-on.jpg
new file mode 100644
index 0000000..c0ffdf4
Binary files /dev/null and b/images/header-menu-on.jpg differ
diff --git a/images/held-arrow-scroll.jpg b/images/held-arrow-scroll.jpg
new file mode 100644
index 0000000..053e266
Binary files /dev/null and b/images/held-arrow-scroll.jpg differ
diff --git a/images/high-contrast-narrow.jpg b/images/high-contrast-narrow.jpg
new file mode 100644
index 0000000..0d71b1f
Binary files /dev/null and b/images/high-contrast-narrow.jpg differ
diff --git a/images/inline-column-filters.jpg b/images/inline-column-filters.jpg
new file mode 100644
index 0000000..ce1a86b
Binary files /dev/null and b/images/inline-column-filters.jpg differ
diff --git a/images/marketplace-dark.png b/images/marketplace-dark.png
new file mode 100644
index 0000000..37047c7
Binary files /dev/null and b/images/marketplace-dark.png differ
diff --git a/images/marketplace-filters.png b/images/marketplace-filters.png
new file mode 100644
index 0000000..b8989a0
Binary files /dev/null and b/images/marketplace-filters.png differ
diff --git a/images/marketplace-light.png b/images/marketplace-light.png
new file mode 100644
index 0000000..14765c6
Binary files /dev/null and b/images/marketplace-light.png differ
diff --git a/images/mouse-selection.jpg b/images/mouse-selection.jpg
new file mode 100644
index 0000000..6ee8a68
Binary files /dev/null and b/images/mouse-selection.jpg differ
diff --git a/images/range-selection.jpg b/images/range-selection.jpg
new file mode 100644
index 0000000..a89e9e6
Binary files /dev/null and b/images/range-selection.jpg differ
diff --git a/images/resize-hit-area.jpg b/images/resize-hit-area.jpg
new file mode 100644
index 0000000..43bfb24
Binary files /dev/null and b/images/resize-hit-area.jpg differ
diff --git a/images/serial-index-menu.jpg b/images/serial-index-menu.jpg
new file mode 100644
index 0000000..8ebd0c0
Binary files /dev/null and b/images/serial-index-menu.jpg differ
diff --git a/media/column-filters.js b/media/column-filters.js
new file mode 100644
index 0000000..b77b851
--- /dev/null
+++ b/media/column-filters.js
@@ -0,0 +1,246 @@
+// Inline filter drafts live in this webview; only complete conditions reach the host.
+window.createColumnFilterControls = ({ root, metadata, onChange, onLayout, onPending, onActivity }) => {
+ const columns = Array.isArray(metadata.columns) ? metadata.columns : [];
+ const maxFilters = Number.isSafeInteger(metadata.maxFilters) ? metadata.maxFilters : 20;
+ let revision = Number.isSafeInteger(metadata.revision) ? metadata.revision : 0;
+ let timer = null;
+ let nextId = 0;
+ let snapshotRows = [];
+ let pending = false;
+ const rows = [];
+ const make = (tag, text) => {
+ const element = document.createElement(tag);
+ if (text !== undefined) element.textContent = text;
+ return element;
+ };
+ const toolbar = make('div');
+ toolbar.className = 'column-filter-toolbar';
+ const summary = make('span');
+ summary.dataset.filterSummary = '';
+ summary.setAttribute('aria-live', 'polite');
+ const addButton = make('button', 'Add filter');
+ addButton.id = 'addColumnFilter';
+ addButton.type = 'button';
+ const clearButton = make('button', 'Clear all filters');
+ clearButton.id = 'clearAllFilters';
+ clearButton.type = 'button';
+ const globalError = make('span');
+ globalError.dataset.filterError = '';
+ globalError.setAttribute('role', 'alert');
+ toolbar.append(summary, addButton, clearButton, globalError);
+ const list = make('div');
+ list.className = 'column-filter-conditions';
+ root.replaceChildren(toolbar, list);
+ root.setAttribute('aria-label', 'Column filters');
+ const isUnary = operator => operator === 'empty' || operator === 'notEmpty';
+ const typedOperator = operator => !['contains', 'notContains', 'regex', 'empty', 'notEmpty'].includes(operator);
+ const columnFor = column => columns.find(item => item.column === column) || columns[0];
+ const setSummary = data => { summary.textContent = `Filtered: ${data.matchedRows ?? 0} of ${data.totalRows ?? 0} rows`; };
+ setSummary(metadata);
+ const updateLayout = () => {
+ root.hidden = rows.length === 0;
+ addButton.disabled = !columns.length || rows.length >= maxFilters;
+ clearButton.disabled = rows.length === 0;
+ rows.forEach((row, index) => { row.and.textContent = index ? 'AND' : ''; });
+ onLayout?.();
+ };
+ const clearErrors = () => {
+ globalError.textContent = '';
+ rows.forEach(row => {
+ row.error.textContent = row.incomplete ? 'Finish editing this condition to apply filters.' : row.invalidOperator ? 'Choose an operator supported by this column type.' : '';
+ row.value.toggleAttribute('aria-invalid', !!row.invalidOperator);
+ if (row.invalidOperator) row.value.setAttribute('aria-invalid', 'true');
+ });
+ };
+ const condition = row => {
+ const column = columnFor(Number(row.column.value));
+ const operator = row.operator.value;
+ if (!column || (!isUnary(operator) && row.value.value.length === 0)) return null;
+ const filter = { column: column.column, operator, value: isUnary(operator) ? '' : row.value.value };
+ if (typedOperator(operator) && column.dataType !== 'string') filter.dataType = column.dataType;
+ return filter;
+ };
+ const send = () => {
+ timer = null;
+ if (rows.some(row => row.composing || row.incomplete)) return;
+ snapshotRows = rows.filter(row => condition(row));
+ onChange({ revision, filters: snapshotRows.map(condition) });
+ };
+ const changed = () => {
+ onActivity?.();
+ // Invalidate older replies immediately, even before debounce/composition ends.
+ revision += 1;
+ pending = !rows.some(row => row.incomplete);
+ onPending?.(pending);
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ clearErrors();
+ if (pending && !rows.some(row => row.composing)) timer = setTimeout(send, 100);
+ updateLayout();
+ };
+ const fillOperators = (row, preferred) => {
+ const column = columnFor(Number(row.column.value));
+ row.operator.replaceChildren();
+ for (const choice of column?.operators || []) {
+ const option = make('option', choice.label);
+ option.value = choice.operator;
+ row.operator.append(option);
+ }
+ row.error.textContent = row.incomplete ? 'Finish editing this condition to apply filters.' : '';
+ row.value.removeAttribute('aria-invalid');
+ row.invalidOperator = !!preferred && !Array.from(row.operator.options).some(option => option.value === preferred);
+ if (row.invalidOperator) {
+ const unavailable = make('option', `${preferred} (unsupported for ${column?.dataType === 'string' ? 'text' : column?.dataType || 'text'})`);
+ unavailable.value = preferred;
+ unavailable.disabled = true;
+ row.operator.append(unavailable);
+ row.error.textContent = 'Choose an operator supported by this column type.';
+ row.value.setAttribute('aria-invalid', 'true');
+ }
+ if (preferred) row.operator.value = preferred;
+ row.value.hidden = isUnary(row.operator.value);
+ row.value.disabled = isUnary(row.operator.value);
+ row.type.textContent = column?.dataType === 'string' ? 'text' : column?.dataType || 'text';
+ };
+ const append = filter => {
+ const column = columnFor(filter?.column);
+ if (!column || rows.length >= maxFilters) return null;
+ const id = ++nextId;
+ const element = make('div');
+ element.className = 'column-filter-row';
+ const and = make('span');
+ and.className = 'column-filter-and';
+ const columnSelect = make('select');
+ columnSelect.dataset.filterColumn = '';
+ columnSelect.setAttribute('aria-label', `Filter ${id} column`);
+ for (const item of columns) {
+ const option = make('option', item.label);
+ option.value = String(item.column);
+ columnSelect.append(option);
+ }
+ columnSelect.value = String(column.column);
+ const type = make('span');
+ type.className = 'column-filter-type';
+ const operator = make('select');
+ operator.dataset.filterOperator = '';
+ operator.setAttribute('aria-label', `Filter ${id} operator`);
+ const value = make('input');
+ value.type = 'text';
+ value.dataset.filterValue = '';
+ value.setAttribute('aria-label', `Filter ${id} value`);
+ value.value = filter?.value || '';
+ const remove = make('button', 'Remove');
+ remove.type = 'button';
+ remove.dataset.removeFilter = '';
+ remove.setAttribute('aria-label', `Remove filter ${id}`);
+ const error = make('span');
+ error.id = `column-filter-error-${id}`;
+ error.className = 'column-filter-error';
+ error.setAttribute('role', 'alert');
+ value.setAttribute('aria-describedby', error.id);
+ const row = { element, and, column: columnSelect, type, operator, value, remove, error, composing: false, incomplete: filter?.composing === true };
+ fillOperators(row, filter?.operator || 'contains');
+ element.append(and, columnSelect, type, operator, value, remove, error);
+ list.append(element);
+ rows.push(row);
+ columnSelect.addEventListener('change', () => { row.incomplete = false; fillOperators(row, operator.value); changed(); });
+ operator.addEventListener('change', () => { row.incomplete = false; row.invalidOperator = false; value.hidden = isUnary(operator.value); value.disabled = isUnary(operator.value); changed(); });
+ value.addEventListener('input', () => { row.incomplete = false; changed(); });
+ value.addEventListener('compositionstart', () => { row.incomplete = false; row.composing = true; changed(); });
+ value.addEventListener('compositionend', () => { row.incomplete = false; row.composing = false; changed(); });
+ remove.addEventListener('click', () => {
+ rows.splice(rows.indexOf(row), 1);
+ element.remove();
+ changed();
+ });
+ return row;
+ };
+ const add = column => {
+ const row = append({ column, operator: 'contains', value: '' });
+ if (!row) return;
+ changed();
+ row.value.focus();
+ };
+ addButton.addEventListener('click', () => add(Number(rows[rows.length - 1]?.column.value ?? columns[0]?.column)));
+ clearButton.addEventListener('click', () => {
+ rows.splice(0).forEach(row => row.element.remove());
+ changed();
+ });
+ const restored = metadata.filterDraft;
+ for (const filter of restored?.rows || metadata.filters || []) append(filter);
+ updateLayout();
+ if (restored) {
+ // This state arrived through one controller's refresh ACK, never VS Code storage.
+ changed();
+ setTimeout(() => {
+ const focus = restored.focus;
+ const field = rows[focus?.row]?.[focus?.field];
+ if (!field || !['column', 'operator', 'value', 'remove'].includes(focus?.field)) return;
+ field.focus({ preventScroll: true });
+ if (focus.field === 'value' && Number.isInteger(focus.selectionStart) && Number.isInteger(focus.selectionEnd)) {
+ field.setSelectionRange(focus.selectionStart, focus.selectionEnd, focus.selectionDirection || 'none');
+ }
+ }, 0);
+ }
+ const captureDraft = () => {
+ if (!rows.length && !pending) return undefined;
+ const draft = {
+ rows: rows.map(row => ({ column: Number(row.column.value), operator: row.operator.value, value: row.value.value, ...((row.composing || row.incomplete) ? { composing: true } : {}) })),
+ session: metadata.session,
+ filterColumnsRevision: metadata.filterColumnsRevision,
+ columns: columns.map(column => ({ column: column.column, label: column.label }))
+ };
+ rows.some((row, index) => {
+ for (const field of ['column', 'operator', 'value', 'remove']) {
+ if (row[field] !== document.activeElement) continue;
+ draft.focus = { row: index, field };
+ if (field === 'value') Object.assign(draft.focus, {
+ selectionStart: row.value.selectionStart,
+ selectionEnd: row.value.selectionEnd,
+ selectionDirection: row.value.selectionDirection
+ });
+ return true;
+ }
+ return false;
+ });
+ return draft;
+ };
+ return {
+ add,
+ captureDraft,
+ retry: changed,
+ get latestRevision() { return revision; },
+ receive(message) {
+ if (message.session !== metadata.session || message.revision !== revision) return false;
+ pending = false;
+ clearErrors();
+ if (Array.isArray(message.columns) && JSON.stringify(message.columns) !== JSON.stringify(columns)) {
+ columns.length = 0;
+ for (const column of message.columns) columns.push(column);
+ for (const row of rows) {
+ const selected = row.column.value;
+ row.column.replaceChildren();
+ for (const column of columns) {
+ const option = make('option', column.label);
+ option.value = String(column.column);
+ row.column.append(option);
+ }
+ row.column.value = selected;
+ fillOperators(row, row.operator.value);
+ }
+ }
+ if (message.error) globalError.textContent = message.error;
+ else if (message.errors?.length) globalError.textContent = 'Showing the last valid results.';
+ for (const item of message.errors || []) {
+ const row = snapshotRows[item.index];
+ if (!row) continue;
+ row.error.textContent = item.message;
+ row.value.setAttribute('aria-invalid', 'true');
+ }
+ if (message.error || message.errors?.length) { setSummary(message); onLayout?.(); return true; }
+ setSummary(message);
+ onLayout?.();
+ return true;
+ }
+ };
+};
diff --git a/media/frozen-panes.js b/media/frozen-panes.js
new file mode 100644
index 0000000..24db08e
--- /dev/null
+++ b/media/frozen-panes.js
@@ -0,0 +1,90 @@
+// Layout uses measured dimensions so multiline cells, manual sizes and zoom agree.
+window.createFrozenPaneManager = ({ table, firstRow, realRowCount, rowOrder, topInset, onNeedRows }) => {
+ let rows = 0;
+ let columns = 0;
+ let scheduled = false;
+ let disposed = false;
+ const count = value => Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : 0;
+ const renderedRows = () => Array.from(table.rows).map(row => {
+ const dataCell = Array.from(row.cells).find(cell => cell.hasAttribute('data-row'));
+ return { element: row, index: Number(dataCell?.getAttribute('data-row')) };
+ }).filter(row => Number.isInteger(row.index));
+ const schedule = () => {
+ if (scheduled || disposed) return;
+ scheduled = true;
+ requestAnimationFrame(() => { scheduled = false; if (!disposed) layout(); });
+ };
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null;
+ const observedRows = new WeakSet();
+ const layout = () => {
+ const rendered = renderedRows();
+ for (const { element } of rendered) {
+ if (!observedRows.has(element)) {
+ observedRows.add(element);
+ observer?.observe(element);
+ }
+ }
+ table.querySelectorAll('.csv-frozen-row, .csv-frozen-column').forEach(cell => {
+ cell.classList.remove('csv-frozen-row', 'csv-frozen-column');
+ cell.style.top = '';
+ cell.style.left = '';
+ cell.style.zIndex = '';
+ });
+ const representative = rendered[0]?.element;
+ const leftByColumn = new Map();
+ let left = 0;
+ if (columns && representative) {
+ for (const cell of Array.from(representative.cells)) {
+ const attr = cell.getAttribute('data-col');
+ const col = attr === null ? -1 : Number(attr);
+ if (col >= columns) break;
+ leftByColumn.set(col, left);
+ left += cell.getBoundingClientRect().width;
+ }
+ }
+ const visibleRows = rowOrder?.length ? rowOrder.filter(row => row < realRowCount) : null;
+ const frozenRows = visibleRows ? new Set(visibleRows.slice(0, rows)) : null;
+ const lastFrozenRow = visibleRows ? (visibleRows[Math.min(rows, visibleRows.length) - 1] ?? -1) : Math.min(firstRow + rows, realRowCount) - 1;
+ const firstFrozenCell = rendered.find(({ index }) => frozenRows ? frozenRows.has(index) : index >= firstRow && index <= lastFrozenRow)?.element.cells[0];
+ // A collapsed border straddles the sticky edge. Cover its inner half once,
+ // keeping the measured spacing between frozen rows unchanged.
+ const topBorder = firstFrozenCell && getComputedStyle(table).borderCollapse === 'collapse'
+ ? parseFloat(getComputedStyle(firstFrozenCell).borderTopWidth) || 0
+ : 0;
+ let top = (topInset?.() || 0) - topBorder / 2;
+ for (const { element, index } of rendered) {
+ const frozenRow = frozenRows ? frozenRows.has(index) : index >= firstRow && index <= lastFrozenRow;
+ const height = element.getBoundingClientRect().height || Math.max(0, ...Array.from(element.cells).map(cell => cell.getBoundingClientRect().height));
+ for (const cell of Array.from(element.cells)) {
+ const attr = cell.getAttribute('data-col');
+ const col = attr === null ? -1 : Number(attr);
+ const frozenColumn = leftByColumn.has(col);
+ if (frozenRow) {
+ cell.classList.add('csv-frozen-row');
+ cell.style.top = `${top}px`;
+ }
+ if (frozenColumn) {
+ cell.classList.add('csv-frozen-column');
+ cell.style.left = `${leftByColumn.get(col)}px`;
+ }
+ if (frozenRow || frozenColumn) {
+ cell.style.zIndex = frozenRow && frozenColumn ? '4' : (frozenRow ? '3' : '2');
+ }
+ }
+ if (frozenRow) top += height;
+ }
+ if (lastFrozenRow >= firstRow && (rendered[rendered.length - 1]?.index ?? -1) < lastFrozenRow) {
+ onNeedRows?.(lastFrozenRow);
+ }
+ };
+ return {
+ schedule,
+ layout,
+ update(nextRows, nextColumns) {
+ rows = count(nextRows);
+ columns = count(nextColumns);
+ schedule();
+ },
+ dispose() { disposed = true; observer?.disconnect(); }
+ };
+};
diff --git a/media/high-contrast.css b/media/high-contrast.css
new file mode 100644
index 0000000..c2c5ee7
--- /dev/null
+++ b/media/high-contrast.css
@@ -0,0 +1,116 @@
+/* Accessibility chrome is limited to high-contrast themes. Cell foregrounds
+ continue to follow the selected datatype palette or explicit theme mode. */
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) {
+ color: var(--vscode-editor-foreground);
+ background-color: var(--vscode-editor-background);
+ --csv-contrast-border: var(--vscode-contrastBorder, var(--vscode-editorWidget-border, var(--vscode-panel-border, var(--vscode-editor-foreground))));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th) {
+ border-color: var(--csv-contrast-border) !important;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) th {
+ background-color: var(--vscode-editor-background) !important;
+}
+
+/* Outlines identify state without covering datatype colors with a bright fill.
+ Reset background images as well, including the opaque frozen-pane layers. */
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).selected {
+ background: var(--vscode-editor-background) !important;
+ outline: 1px solid var(--vscode-focusBorder, var(--csv-contrast-border));
+ outline-offset: -1px;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).highlight {
+ background: var(--vscode-editor-background) !important;
+ outline: 1px solid var(--vscode-editor-findMatchHighlightBorder, var(--csv-contrast-border));
+ outline-offset: -1px;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).active-match {
+ background: var(--vscode-editor-background) !important;
+ outline: 2px solid var(--vscode-editor-findMatchBorder, var(--vscode-focusBorder));
+ outline-offset: -2px;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link {
+ color: var(--vscode-textLink-foreground, var(--vscode-editor-foreground));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link:hover {
+ color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) .selected .csv-link {
+ color: inherit;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu {
+ color: var(--vscode-menu-foreground, var(--vscode-editor-foreground));
+ background-color: var(--vscode-menu-background, var(--vscode-editor-background));
+ border-color: var(--csv-contrast-border);
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div {
+ border-color: var(--csv-contrast-border) !important;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div:hover {
+ color: var(--vscode-menu-selectionForeground, var(--vscode-editor-foreground));
+ background-color: var(--vscode-menu-selectionBackground, var(--vscode-editor-background));
+ outline: 1px solid var(--vscode-menu-selectionBorder, var(--csv-contrast-border));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget {
+ color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground));
+ background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background));
+ border-color: var(--csv-contrast-border);
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-gutter, .fr-overflow-menu) {
+ background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background));
+ border-color: var(--csv-contrast-border);
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input {
+ color: var(--vscode-input-foreground, var(--vscode-editor-foreground));
+ background-color: var(--vscode-input-background, var(--vscode-editor-background));
+ border-color: var(--vscode-input-border, var(--csv-contrast-border));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input::placeholder {
+ color: var(--vscode-input-placeholderForeground, var(--vscode-editor-foreground));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-status, button) {
+ color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button:hover {
+ color: var(--vscode-toolbar-hoverForeground, var(--vscode-editor-foreground));
+ background-color: var(--vscode-toolbar-hoverBackground, var(--vscode-editor-background));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-toggle-btn[aria-pressed="true"] {
+ color: var(--vscode-inputOption-activeForeground, var(--vscode-editor-foreground));
+ background-color: var(--vscode-inputOption-activeBackground, var(--vscode-editor-background));
+ box-shadow: inset 0 -2px 0 var(--vscode-inputOption-activeBorder, var(--csv-contrast-border));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button[disabled] {
+ color: var(--vscode-disabledForeground, var(--vscode-editor-foreground));
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(button, input):focus-visible {
+ outline: 2px solid var(--vscode-focusBorder, var(--csv-contrast-border));
+ outline-offset: -2px;
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-divider {
+ background-color: var(--csv-contrast-border);
+}
+
+body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-inline-toggles {
+ border-color: var(--csv-contrast-border);
+}
diff --git a/media/large-preview.js b/media/large-preview.js
new file mode 100644
index 0000000..d758553
--- /dev/null
+++ b/media/large-preview.js
@@ -0,0 +1,30 @@
+const vscode = acquireVsCodeApi();
+const status = document.getElementById('status');
+const records = document.getElementById('records');
+const error = document.getElementById('error');
+const buttons = ['start', 'previous', 'next'].map(id => document.getElementById(id));
+const busy = () => {
+ buttons.forEach(button => { button.disabled = true; });
+ status.textContent = 'Loading saved file…';
+};
+buttons.forEach(button => {
+ button.addEventListener('click', () => {
+ busy();
+ vscode.postMessage({ type: button.id });
+ });
+});
+window.addEventListener('message', event => {
+ if (event.data.type !== 'page') return;
+ const message = event.data;
+ // Markup is built by the extension from escaped cell values. Replacing the
+ // page bounds DOM growth regardless of how many pages have been visited.
+ records.innerHTML = message.html;
+ records.scrollTop = records.scrollLeft = 0;
+ status.textContent = message.status;
+ error.textContent = message.error || '';
+ buttons[0].disabled = false;
+ buttons[1].disabled = !message.canBack;
+ buttons[2].disabled = !message.canNext;
+});
+busy();
+vscode.postMessage({ type: 'ready' });
diff --git a/media/main.js b/media/main.js
index 58079a4..525f3fe 100644
--- a/media/main.js
+++ b/media/main.js
@@ -6,7 +6,21 @@ try { document.body.focus({ preventScroll: true }); } catch { try { document.bod
const vscode = acquireVsCodeApi();
const root = document.getElementById('csv-root');
-const CSV_SEPARATOR = String.fromCodePoint(parseInt(root?.dataset?.sepcode || '44', 10)); // default ','
+let filterMetadata = {};
+try { filterMetadata = JSON.parse(document.getElementById('__csvFilters')?.textContent || '{}'); } catch {}
+const filterSession = filterMetadata.session;
+let visibleRowOrder = [];
+try { visibleRowOrder = JSON.parse(root?.dataset?.roworder || '[]'); } catch {}
+let headerSourceRow = Number(root?.dataset?.firstrow || '0');
+let filterEpoch = 0;
+let filterSnapshotPending = false;
+const adjacentVisibleRow = (row, direction) => {
+ if (!visibleRowOrder.length) return row + direction;
+ const index = visibleRowOrder.indexOf(row);
+ return visibleRowOrder[index + direction] ?? (row >= Number(root?.dataset?.realrowcount || '0') ? row + direction : row);
+};
+let frozenRowCount = Number(root?.dataset?.frozenrows || '0');
+let frozenColumnCount = Number(root?.dataset?.frozencolumns || '0');
const parsePositiveNumber = value => {
const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
@@ -28,12 +42,15 @@ let lastContextIsHeader = false; // remembers whether we right-clicked a