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 +![CSV table in a dark theme](images/marketplace-dark.png) +![CSV table in a light theme](images/marketplace-light.png) -![Dark Theme Screenshot](images/Screenshot_dark.png) -![Light Theme Screenshot](images/Screenshot_light.png) +## 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. +![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. -## 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 let isUpdating = false, isSelecting = false, anchorCell = null, rangeEndCell = null, currentSelection = []; let startCell = null, endCell = null, selectionMode = "cell"; let editingCell = null, originalCellValue = ""; +const failedCellDrafts = new Set(); +let discardingFailedDraft = false; // Edit mode: // - 'quick': started by typing a character (not Enter) // - 'detail': started by Enter or double-click let editMode = null; // 'quick' | 'detail' | null const DRAG_THRESHOLD_PX = 4; const RESIZE_HANDLE_PX = 6; +const COLUMN_RESIZE_HANDLE_PX = 4; let resizeState = null; let reorderState = null; @@ -46,6 +63,9 @@ dragIndicator.style.zIndex = '20000'; dragIndicator.style.background = '#0a84ff'; dragIndicator.style.display = 'none'; document.body.appendChild(dragIndicator); +const automaticColumnSizes = new WeakMap(); +let persistedColumnSizes = {}; +try { persistedColumnSizes = JSON.parse(root?.dataset?.columnsizes || '{}'); } catch {} let columnSizeState = {}; let rowSizeState = {}; @@ -62,13 +82,24 @@ const normalizeSizeState = (raw, minSize) => { return out; }; +const setRenderedColumnWidth = (cell, px) => { + if (!automaticColumnSizes.has(cell)) { + automaticColumnSizes.set(cell, { width: cell.style.width, minWidth: cell.style.minWidth, maxWidth: cell.style.maxWidth, boxSizing: cell.style.boxSizing }); + } + cell.style.boxSizing = 'border-box'; + cell.style.width = `${px}px`; + cell.style.minWidth = `${px}px`; + cell.style.maxWidth = `${px}px`; +}; +const persistColumnSizes = () => { + vscode.postMessage({ type: 'columnSizesChanged', columnSizes: { ...columnSizeState } }); +}; const applySizeStateToRenderedCells = () => { + window.csvFrozenPanes?.schedule(); for (const [col, width] of Object.entries(columnSizeState)) { const px = Math.max(40, Math.round(Number(width))); table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = `${px}px`; - cell.style.minWidth = `${px}px`; - cell.style.maxWidth = `${px}px`; + setRenderedColumnWidth(cell, px); }); } for (const [row, height] of Object.entries(rowSizeState)) { @@ -96,6 +127,8 @@ const setZoomScale = (nextScale, persist = true) => { return false; } zoomScale = normalized; + const menu = document.getElementById('contextMenu'); + if (menu) { menu.style.display = 'none'; } document.body.style.fontSize = `${Math.max(1, BASE_FONT_SIZE_PX * zoomScale)}px`; applySizeStateToRenderedCells(); if (persist) { @@ -149,12 +182,79 @@ const persistState = () => { } catch {} }; +// Coalesce repeated navigation to the latest cell before paint. Native smooth +// scrolling restarts on every key repeat and can leave selection offscreen. +let pendingCellReveal = null; +let cellRevealScheduled = false; +const scheduleCellReveal = (cell, center = false) => { + pendingCellReveal = { cell, center }; + if (cellRevealScheduled) return; + cellRevealScheduled = true; + requestAnimationFrame(() => { + cellRevealScheduled = false; + const request = pendingCellReveal; + pendingCellReveal = null; + if (!request?.cell?.isConnected || !scrollContainer) return; + const width = scrollContainer.clientWidth; + const height = scrollContainer.clientHeight; + if (width <= 0 || height <= 0) return; + const viewport = scrollContainer.getBoundingClientRect(); + const viewportLeft = viewport.left + scrollContainer.clientLeft; + const viewportTop = viewport.top + scrollContainer.clientTop; + let left = Math.max(0, viewportLeft); + let top = Math.max(0, viewportTop); + const right = Math.min(window.innerWidth, viewportLeft + width); + const bottom = Math.min(window.innerHeight, viewportTop + height); + const target = request.cell; + let fixedTop = target.classList.contains('csv-frozen-row'); + const fixedLeft = target.classList.contains('csv-frozen-column'); + + const filterBar = document.getElementById('filterStatus'); + if (filterBar) top = Math.max(top, filterBar.getBoundingClientRect().bottom); + const headerCell = table.tHead?.rows[0]?.cells[0]; + if (headerCell) { + const style = getComputedStyle(headerCell); + if (style.position === 'sticky' && style.top && style.top !== 'auto') { + top = Math.max(top, headerCell.getBoundingClientRect().bottom); + fixedTop = fixedTop || table.tHead.contains(target); + } + } + // Frozen panes are leading rows/columns. Read one representative per + // boundary, rather than scanning every rendered cell on each repeat. + for (const row of table.rows) { + const first = row.cells[0]; + if (!first?.classList.contains('csv-frozen-row')) break; + top = Math.max(top, first.getBoundingClientRect().bottom); + } + for (const cell of table.rows[0]?.cells || []) { + if (!cell.classList.contains('csv-frozen-column')) break; + left = Math.max(left, cell.getBoundingClientRect().right); + } + + const rect = target.getBoundingClientRect(); + const revealDelta = (start, end, min, max) => { + if (max <= min) return 0; + // A cell larger than the available pane cannot fit; keep its leading + // edge usable instead of alternating between opposite edges. + if (end - start > max - min) return start - min; + if (request.center) return (start + end - min - max) / 2; + return start < min ? start - min : end > max ? end - max : 0; + }; + const x = fixedLeft ? 0 : revealDelta(rect.left, rect.right, left, right); + const y = fixedTop ? 0 : revealDelta(rect.top, rect.bottom, top, bottom); + // Even zero delta cancels any native animation already in flight. + scrollContainer.scrollTo({ left: scrollContainer.scrollLeft + x, top: scrollContainer.scrollTop + y, behavior: 'instant' }); + persistState(); + }); +}; + const restoreState = () => { + if (document.activeElement?.closest?.('#filterStatus')) return; try { const st = vscode.getState() || {}; const restoredZoom = parsePositiveNumber(st.zoomScale); setZoomScale(restoredZoom ?? 1, false); - columnSizeState = normalizeSizeState(st.columnSizes, 40); + columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40); rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight()); applySizeStateToRenderedCells(); if (typeof st.scrollX === 'number' && scrollContainer) { @@ -179,14 +279,14 @@ const restoreState = () => { } } if (typeof st.anchorRow === 'number' && typeof st.anchorCol === 'number') { - const tag = (hasHeader && st.anchorRow === 0 ? 'th' : 'td'); - let sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); + const selector = `td[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"], th[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`; + let sel = table.querySelector(selector); // If not present yet (due to chunking), load chunks until available or exhausted if (!sel && typeof window.__csvLoadNextChunk === 'function') { let guard = 100; // prevent infinite loops while (!sel && typeof window.__csvLoadNextChunk === 'function' && guard-- > 0) { if (!window.__csvLoadNextChunk()) break; - sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); + sel = table.querySelector(selector); } } applySizeStateToRenderedCells(); @@ -237,73 +337,46 @@ const requestRemoteChunk = () => { remoteChunkRequestInFlight = true; remoteChunkRequestedStart = remoteNextChunkStart; remoteChunkRequestSeq += 1; - vscode.postMessage({ type: 'requestChunk', start: remoteNextChunkStart, requestId: remoteChunkRequestSeq }); + vscode.postMessage({ type: 'requestChunk', start: remoteNextChunkStart, requestId: remoteChunkRequestSeq, filterEpoch, session: filterSession }); }; -if (csvChunks.length || remoteHasMoreChunks) { +let chunkObserver = null; +let chunkLoading = false; +const primeChunks = () => { + chunkObserver?.disconnect(); + const last = table.tBodies[0]?.querySelector('tr:last-child'); + if (last && (csvChunks.length || remoteHasMoreChunks)) chunkObserver?.observe(last); +}; +loadNextChunk = () => { const tbody = table.tBodies[0]; - let loading = false; - - loadNextChunk = () => { - if (loading || !tbody) return false; - if (!csvChunks.length) { - requestRemoteChunk(); - return false; - } - loading = true; - try { - const html = csvChunks.shift(); - if (html) { - tbody.insertAdjacentHTML('beforeend', html); - applySizeStateToRenderedCells(); - window.dispatchEvent(new Event('csvChunkLoaded')); - } - if (!csvChunks.length) { - requestRemoteChunk(); - } - return !!html; - } finally { - loading = false; - } - }; - // Expose for restoration logic - window.__csvLoadNextChunk = loadNextChunk; - - nearBottom = () => { - if (!scrollContainer) return false; - const remain = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight; - return remain < 300; // px threshold - }; - - const io = new IntersectionObserver((entries)=>{ - if (entries[0] && entries[0].isIntersecting) { - loadNextChunk(); - const last = tbody && tbody.querySelector('tr:last-child'); - if (last) io.observe(last); - } - }, { root: scrollContainer || null, rootMargin: '0px 0px 300px 0px' }); - - const prime = () => { - const last = tbody && tbody.querySelector('tr:last-child'); - if (last) { io.observe(last); } - }; - prime(); - - // Fallback: scroll-driven loader to ensure progress even if IO misses - const scrollHandler = () => { - if (!csvChunks.length && !remoteHasMoreChunks) return; - if (nearBottom()) { - // Load until we create headroom or exhaust currently available chunks - let guard = 10; - while (nearBottom() && guard-- > 0) { - if (!loadNextChunk()) break; - } - prime(); + if (chunkLoading || !tbody) return false; + if (!csvChunks.length) { requestRemoteChunk(); return false; } + chunkLoading = true; + try { + const html = csvChunks.shift(); + if (html) { + tbody.insertAdjacentHTML('beforeend', html); + applySizeStateToRenderedCells(); + window.dispatchEvent(new Event('csvChunkLoaded')); } - }; - if (scrollContainer) scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }); - else window.addEventListener('scroll', scrollHandler, { passive: true }); -} + if (!csvChunks.length) requestRemoteChunk(); + primeChunks(); + return !!html; + } finally { chunkLoading = false; } +}; +window.__csvLoadNextChunk = loadNextChunk; +nearBottom = () => !!scrollContainer && scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 300; +chunkObserver = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadNextChunk(); +}, { root: scrollContainer || null, rootMargin: '0px 0px 300px 0px' }); +primeChunks(); +const scrollChunkLoader = () => { + if (!csvChunks.length && !remoteHasMoreChunks) return; + let guard = 10; + while (nearBottom() && guard-- > 0) { if (!loadNextChunk()) break; } + primeChunks(); +}; +(scrollContainer || window).addEventListener('scroll', scrollChunkLoader, { passive: true }); const ensureTargetStep = () => { if (!pendingEnsureTarget) return; @@ -367,18 +440,28 @@ const handleZoomWheel = e => { }; window.addEventListener('wheel', handleZoomWheel, { passive: false }); -const hasHeader = document.querySelector('thead') !== null; +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 = []; }; const contextMenu = document.getElementById('contextMenu'); /* ──────── UPDATED showContextMenu ──────── */ +const changeFrozenPanes = change => { + frozenRowCount = change.rows ?? frozenRowCount; + frozenColumnCount = change.columns ?? frozenColumnCount; + window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount); + vscode.postMessage({ type: 'freezePanesChanged', rows: frozenRowCount, columns: frozenColumnCount }); +}; const showContextMenu = (x, y, row, col) => { contextMenu.innerHTML = ''; const item = (label, cb) => { const d = document.createElement('div'); d.textContent = label; - d.addEventListener('click', () => { cb(); contextMenu.style.display = 'none'; }); + d.addEventListener('click', () => { + if (!filterSnapshotPending) cb(); + contextMenu.style.display = 'none'; + }); contextMenu.appendChild(d); }; const divider = () => { @@ -387,6 +470,49 @@ const showContextMenu = (x, y, row, col) => { d.style.margin = '1px 0'; contextMenu.appendChild(d); }; + if (col === -1) { + item('Hide serial index column', () => { + const anchor = anchorCell ? getCellCoords(anchorCell) : null; + if (!anchor || !Number.isFinite(anchor.col) || anchor.col < 0) { + const row = anchor && Number.isFinite(anchor.row) ? anchor.row : Number(root?.dataset?.firstrow || '0'); + const surviving = table.querySelector(`td[data-row="${row}"][data-col="0"], th[data-row="${row}"][data-col="0"]`); + if (surviving) { + clearSelection(); + surviving.classList.add('selected'); + currentSelection.push(surviving); + anchorCell = surviving; + rangeEndCell = surviving; + } + } + persistState(); + vscode.postMessage({ type: 'setSerialIndex', enabled: false }); + }); + divider(); + } + if (row === Number(root?.dataset?.firstrow)) { + item(headerEnabled ? 'Treat first row as data' : 'Use first row as header', () => { + persistState(); + vscode.postMessage({ type: 'setHeader', enabled: !headerEnabled }); + }); + divider(); + } + if (Number.isInteger(col) && col >= 0) { + item('Filter this column…', () => window.csvColumnFilters?.add(col)); + divider(); + } + item('Cut', () => copySelectionToClipboard(true)); + item('Copy', () => copySelectionToClipboard()); + item('Paste', () => pasteFromClipboard()); + divider(); + if (Number.isInteger(row) && row >= Number(root?.dataset?.firstrow || '0') && row < Number(root?.dataset?.realrowcount || '0')) { + item('Freeze rows through this row', () => changeFrozenPanes({ rows: row - Number(root?.dataset?.firstrow || '0') + 1 })); + } + if (Number.isInteger(col) && col >= 0) { + item('Freeze columns through this column', () => changeFrozenPanes({ columns: col + 1 })); + } + if (frozenRowCount > 0) item('Unfreeze rows', () => changeFrozenPanes({ rows: 0 })); + if (frozenColumnCount > 0) item('Unfreeze columns', () => changeFrozenPanes({ columns: 0 })); + divider(); // Derive multi-row/column selection counts const selectedIndexCells = currentSelection.filter(el => el && el.getAttribute && el.getAttribute('data-col') === '-1'); const selectedRowIds = Array.from(new Set(selectedIndexCells.map(el => parseInt(el.getAttribute('data-row') || '-1', 10)).filter(n => !isNaN(n)))).sort((a,b)=>a-b); @@ -400,9 +526,9 @@ const showContextMenu = (x, y, row, col) => { /* Header-only: SORT functionality */ if (lastContextIsHeader) { - item('Sort: A-Z', () => + item(root?.dataset?.filtered === '1' ? 'Sort matching rows: A-Z' : 'Sort: A-Z', () => vscode.postMessage({ type: 'sortColumn', index: col, ascending: true })); - item('Sort: Z-A', () => + item(root?.dataset?.filtered === '1' ? 'Sort matching rows: Z-A' : 'Sort: Z-A', () => vscode.postMessage({ type: 'sortColumn', index: col, ascending: false })); } @@ -457,10 +583,25 @@ const showContextMenu = (x, y, row, col) => { }); } - contextMenu.style.left = x + 'px'; - contextMenu.style.top = y + 'px'; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const marginX = Math.min(8, viewportWidth / 4); + const marginY = Math.min(8, viewportHeight / 4); + contextMenu.style.maxWidth = `${Math.max(0, viewportWidth - 2 * marginX)}px`; + contextMenu.style.maxHeight = `${Math.max(0, viewportHeight - 2 * marginY)}px`; + // Measure all actions at their natural width, bounded by the viewport, before placing. + contextMenu.style.left = `${marginX}px`; + contextMenu.style.top = `${marginY}px`; contextMenu.style.display = 'block'; + contextMenu.scrollTop = 0; + contextMenu.scrollLeft = 0; + const { width, height } = contextMenu.getBoundingClientRect(); + const left = x + width <= viewportWidth - marginX ? x : x - width; + const top = y + height <= viewportHeight - marginY ? y : y - height; + contextMenu.style.left = `${clamp(left, marginX, Math.max(marginX, viewportWidth - marginX - width))}px`; + contextMenu.style.top = `${clamp(top, marginY, Math.max(marginY, viewportHeight - marginY - height))}px`; }; +window.addEventListener('resize', () => { contextMenu.style.display = 'none'; }); const getElementTarget = target => { if (target instanceof Element) return target; @@ -557,17 +698,60 @@ const getRowDropTarget = clientY => { return { beforeIndex, indicatorY }; }; const getResizeEdgeInfo = (target, e) => { - if (!target) return null; - if (isColumnHeaderCell(target)) { - const col = parseInt(target.getAttribute('data-col') || 'NaN', 10); - if (!Number.isNaN(col)) { - const rect = target.getBoundingClientRect(); - const edgeDelta = rect.right - e.clientX; - if (edgeDelta >= 0 && edgeDelta <= RESIZE_HANDLE_PX) { - return { axis: 'column', index: col, rect }; - } + if (editingCell || !Number.isFinite(e.clientX) || !Number.isFinite(e.clientY)) return null; + const element = getElementTarget(e.target); + if (element?.closest('a, .csv-link, input, textarea, select, button, [contenteditable="true"], [role="button"]')) return null; + const paintedCellAt = (x, y) => { + const hit = document.elementFromPoint(x, y); + return hit && table.contains(hit) ? getCellTarget(hit) : null; + }; + const candidates = new Set(); + const add = cell => { if (cell && table.contains(cell)) candidates.add(cell); }; + if (target) { + add(target); + // A sticky seam can lie inside a partially covered scrolling cell's rect. + add(paintedCellAt(e.clientX - COLUMN_RESIZE_HANDLE_PX - 0.5, e.clientY)); + const rect = target.getBoundingClientRect(); + if (rect.width > 0 && Math.abs(e.clientX - rect.left) <= COLUMN_RESIZE_HANDLE_PX) { + // Hit the painted neighbor, which can differ from col-1 at a frozen seam. + add(paintedCellAt(rect.left - 0.5, e.clientY)); + add(paintedCellAt(rect.left - 1, e.clientY)); + add(target.previousElementSibling); + } + } else { + // Collapsed/fractional borders can target TR/TABLE rather than either cell. + for (const delta of [0, -0.5, 0.5, -1, 1]) { + add(paintedCellAt(e.clientX + delta, e.clientY)); } } + let closest = null; + let closestDistance = Infinity; + for (const cell of candidates) { + const coords = getDataCellCoords(cell); + if (!coords) continue; + const rect = cell.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || e.clientY < rect.top - 0.5 || e.clientY > rect.bottom + 0.5 || Math.abs(e.clientX - rect.right) > COLUMN_RESIZE_HANDLE_PX + 0.75) continue; + const sampleY = clamp(e.clientY, rect.top + Math.min(0.5, rect.height / 4), rect.bottom - Math.min(0.5, rect.height / 4)); + // A scrolled cell can share the frozen column's x coordinate while covered. + if (paintedCellAt(rect.right - Math.min(0.5, rect.width / 4), sampleY) !== cell) continue; + const neighbor = paintedCellAt(rect.right + 0.5, sampleY); + const next = neighbor && neighbor !== cell && neighbor.parentElement === cell.parentElement ? neighbor : cell.nextElementSibling; + const nextRect = next && getDataCellCoords(next) ? next.getBoundingClientRect() : null; + const adjacent = nextRect && nextRect.width > 0 && nextRect.left <= rect.right + 1.5 && nextRect.right > rect.right; + const contiguous = adjacent && Math.abs(nextRect.left - rect.right) <= 1.5; + const boundary = contiguous ? (rect.right + nextRect.left) / 2 : rect.right; + const leftCover = paintedCellAt(rect.left + Math.min(0.5, rect.width / 4), sampleY); + const visibleLeft = leftCover && leftCover !== cell ? Math.min(rect.right, Math.max(rect.left, leftCover.getBoundingClientRect().right)) : rect.left; + const nextWidth = adjacent ? nextRect.right - Math.max(nextRect.left, rect.right) : Infinity; + // Use the painted span at sticky seams so even partially covered cells retain a center. + const band = Math.min(COLUMN_RESIZE_HANDLE_PX, (rect.right - visibleLeft) / 4, nextWidth / 4); + const distance = Math.abs(e.clientX - boundary); + if (distance <= band && distance < closestDistance) { + closestDistance = distance; + closest = { axis: 'column', index: coords.col, rect }; + } + } + if (closest) return closest; if (isRowIndexCell(target)) { const row = parseInt(target.getAttribute('data-row') || 'NaN', 10); if (!Number.isNaN(row)) { @@ -581,23 +765,23 @@ const getResizeEdgeInfo = (target, e) => { return null; }; const applyColumnWidth = (col, widthPx) => { + window.csvFrozenPanes?.schedule(); const width = Math.max(40, Math.round(widthPx)); columnSizeState[String(col)] = width; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = `${width}px`; - cell.style.minWidth = `${width}px`; - cell.style.maxWidth = `${width}px`; + setRenderedColumnWidth(cell, width); }); }; const resetColumnWidth = col => { + window.csvFrozenPanes?.schedule(); delete columnSizeState[String(col)]; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = ''; - cell.style.minWidth = ''; - cell.style.maxWidth = ''; + const automatic = automaticColumnSizes.get(cell); + if (automatic) Object.assign(cell.style, automatic); }); }; const applyRowHeight = (row, heightPx) => { + window.csvFrozenPanes?.schedule(); const height = Math.max(getMinRowHeight(), Math.round(heightPx)); rowSizeState[String(row)] = height; table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => { @@ -606,6 +790,7 @@ const applyRowHeight = (row, heightPx) => { }); }; const resetRowHeight = row => { + window.csvFrozenPanes?.schedule(); delete rowSizeState[String(row)]; table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => { cell.style.height = ''; @@ -640,7 +825,8 @@ const startReorderDrag = (target, e) => { startX: e.clientX, startY: e.clientY, active: false, - beforeIndex: null + beforeIndex: null, + headerOnlyOnClick: e.detail === 1 && selectionMode === 'column' && anchorCell === target && selected.length === 1 ? target : null }; return true; } @@ -695,20 +881,32 @@ const onGlobalDragMove = e => { showRowDropIndicator(target.indicatorY); } }; -const onGlobalDragEnd = () => { +const onGlobalDragEnd = e => { if (resizeState) { + if (resizeState.axis === 'column') persistColumnSizes(); resizeState = null; table.style.cursor = ''; persistState(); + window.csvFrozenPanes?.schedule(); } if (!reorderState) return; - const { axis, indices, active, beforeIndex } = reorderState; + const { axis, indices, active, beforeIndex, headerOnlyOnClick } = reorderState; reorderState = null; hideDragIndicator(); table.style.cursor = ''; - if (!active || !Number.isFinite(beforeIndex)) return; + if (!active) { + // Keep selected columns available for dragging until an ordinary click ends. + // A real double-click has detail 2 and keeps the existing edit gesture. + if (headerOnlyOnClick && e.button === 0 && getCellTarget(e.target) === headerOnlyOnClick) { + selectionMode = 'cell'; + startCell = endCell = headerOnlyOnClick; + setSingleSelection(headerOnlyOnClick); + } + return; + } + if (!Number.isFinite(beforeIndex)) return; if (axis === 'column') { vscode.postMessage({ type: 'reorderColumns', indices, beforeIndex }); } else { @@ -747,15 +945,16 @@ document.addEventListener('click', (e) => { /* ──────── UPDATED contextmenu listener ──────── */ table.addEventListener('contextmenu', e => { const target = getCellTarget(e.target); - if (!target) return; + if (!target || editingCell) return; const colAttr = target.getAttribute('data-col'); const rowAttr = target.getAttribute('data-row'); - const col = parseInt(colAttr); + const serialCorner = target.hasAttribute('data-serial-index'); + const col = serialCorner ? -1 : parseInt(colAttr); const row = parseInt(rowAttr); - if ((isNaN(col) || col === -1) && (isNaN(row) || row === -1)) return; + if (!serialCorner && (isNaN(col) || col === -1) && (isNaN(row) || row === -1)) return; e.preventDefault(); - lastContextIsHeader = target.tagName === 'TH'; - showContextMenu(e.pageX, e.pageY, row, col); + lastContextIsHeader = target.tagName === 'TH' && !serialCorner; + showContextMenu(e.clientX, e.clientY, row, col); }); table.addEventListener('mousedown', e => { @@ -766,6 +965,10 @@ table.addEventListener('mousedown', e => { return; } const target = getCellTarget(e.target); + if (e.button === 0 && startResizeDrag(target, e)) { + e.preventDefault(); + return; + } if (!target) return; // Preserve selection on right-click; select target if outside current selection @@ -784,10 +987,6 @@ table.addEventListener('mousedown', e => { return; // do not start drag selection on right-click } if (e.button !== 0) return; - if (!editingCell && startResizeDrag(target, e)) { - e.preventDefault(); - return; - } if (!editingCell && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && startReorderDrag(target, e)) { e.preventDefault(); return; @@ -855,6 +1054,16 @@ table.addEventListener('mousedown', e => { selectionMode = (target.tagName === 'TH') ? "column" : (target.getAttribute('data-col') === '-1' ? "row" : "cell"); startCell = target; endCell = target; rangeEndCell = target; isSelecting = true; e.preventDefault(); + if (selectionMode === "column") { + const col = Number(target.getAttribute('data-col')); + selectFullColumnRange(col, col); + } else if (selectionMode === "row") { + const row = Number(target.getAttribute('data-row')); + selectFullRowRange(row, row); + } else { + const coords = getCellCoords(target); + selectRange(coords, coords); + } target.focus(); }); @@ -894,10 +1103,6 @@ table.addEventListener('mousemove', e => { return; } const target = getCellTarget(e.target); - if (!target) { - table.style.cursor = ''; - return; - } const edge = getResizeEdgeInfo(target, e); if (edge) { table.style.cursor = edge.axis === 'column' ? 'col-resize' : 'row-resize'; @@ -916,11 +1121,6 @@ table.addEventListener('mouseup', e => { if(!isSelecting) return; isSelecting = false; if(selectionMode === "cell"){ - if(startCell === endCell){ - clearSelection(); - startCell.classList.add('selected'); - currentSelection.push(startCell); - } anchorCell = startCell; rangeEndCell = endCell; persistState(); @@ -939,9 +1139,10 @@ 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); - for(let r = minRow; r <= maxRow; r++){ + 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++){ - const selector = (hasHeader && r === 0 ? 'th' : 'td') + '[data-row="'+r+'"][data-col="'+c+'"]'; + const selector = (hasHeader && r === headerSourceRow ? 'th' : 'td') + '[data-row="'+r+'"][data-col="'+c+'"]'; const selCell = table.querySelector(selector); if(selCell){ selCell.classList.add('selected'); currentSelection.push(selCell); } } @@ -974,6 +1175,19 @@ const selectFullRowRange = (row1, row2) => { }); }; +const extendSelectionTo = target => { + rangeEndCell = target; + const start = getCellCoords(anchorCell); + const end = getCellCoords(target); + if (isColumnHeaderCell(anchorCell)) { + selectFullColumnRange(start.col, end.col); + } else if (isRowIndexCell(anchorCell)) { + selectFullRowRange(start.row, end.row); + } else { + selectRange(start, end); + } +}; + const getDataCellCoords = cell => { if (!cell || typeof cell.getAttribute !== 'function') return null; const coords = getCellCoords(cell); @@ -994,7 +1208,8 @@ const getDataSelectionBounds = () => { const maxRow = Math.max(...rows); const minCol = Math.min(...cols); const maxCol = Math.max(...cols); - const expectedCount = (maxRow - minRow + 1) * (maxCol - minCol + 1); + const rowIds = [...new Set(rows)].sort((a, b) => a - b); + const expectedCount = rowIds.length * (maxCol - minCol + 1); const rectangular = keys.size === expectedCount; return { minRow, maxRow, minCol, maxCol, rectangular }; }; @@ -1052,6 +1267,19 @@ const isFindWidgetTarget = target => { const el = getElementTarget(target); return !!(el && el.closest('#findReplaceWidget')); }; +const isGridControlTarget = target => isFindWidgetTarget(target) || !!getElementTarget(target)?.closest('#filterStatus'); +const preventPendingGridAction = event => { + if (!filterSnapshotPending || isGridControlTarget(event.target)) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; +for (const type of ['keydown', 'copy', 'cut', 'paste']) { + document.addEventListener(type, preventPendingGridAction, true); +} +for (const type of ['mousedown', 'mouseup', 'dblclick', 'contextmenu', 'beforeinput']) { + table.addEventListener(type, preventPendingGridAction, true); +} + const clearFindHighlights = () => { document.querySelectorAll('.highlight, .active-match').forEach(el => { el.classList.remove('highlight'); @@ -1165,7 +1393,7 @@ const setActiveFindMatch = (index, shouldScroll = true) => { cell.classList.add('highlight'); cell.classList.add('active-match'); if (shouldScroll) { - cell.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + scheduleCellReveal(cell, true); } } updateFindStatus(); @@ -1234,6 +1462,7 @@ const replaceInText = (text, replaceAllMatches) => { return text.replace(regex, matched => preserveReplacementCase(replacementText, matched)); }; const replaceCurrentMatch = () => { + if (filterSnapshotPending) return; if (!findMatches.length || findReplaceState.invalidRegex) return; const match = findMatches[currentMatchIndex]; if (!match) { @@ -1254,6 +1483,7 @@ const replaceCurrentMatch = () => { runFind(true); }; const replaceAllMatches = () => { + if (filterSnapshotPending) return; if (findReplaceState.invalidRegex || !findInput.value || !findMatches.length) return; const seen = new Set(); if (!findMatches.length) return; @@ -1313,41 +1543,46 @@ const closeFindReplace = () => { findInput.addEventListener('input', () => scheduleFind(false)); findInput.addEventListener('keydown', e => { - e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); + e.stopPropagation(); closeFindReplace(); return; } if (e.key === 'Enter') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); return; } if (e.key === 'F3') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'g') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); } }); replaceInput.addEventListener('keydown', e => { - e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); + e.stopPropagation(); closeFindReplace(); return; } if (e.key === 'F3') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); return; } if (e.key === 'Enter') { e.preventDefault(); + e.stopPropagation(); replaceCurrentMatch(); } }); @@ -1414,9 +1649,9 @@ window.addEventListener('csvChunkLoaded', () => { } }); -// Capture-phase handler to intercept Cmd/Ctrl + Arrow and move to extremes +// Handle modified arrow jumps once; Shift extends the existing selection. document.addEventListener('keydown', e => { - if (isFindWidgetTarget(e.target)) { + if (isGridControlTarget(e.target) || isTextInputTarget(e.target)) { return; } const isArrowKey = (k) => ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Up','Down','Left','Right','Home','End'].includes(k); @@ -1425,38 +1660,26 @@ document.addEventListener('keydown', e => { e.stopImmediatePropagation(); e.stopPropagation(); - const sc = document.querySelector('.table-container'); - if (sc) { - if (['ArrowLeft','Left','Home'].includes(e.key)) sc.scrollTo({ left: 0, behavior: 'smooth' }); - if (['ArrowRight','Right','End'].includes(e.key)) sc.scrollTo({ left: sc.scrollWidth, behavior: 'smooth' }); - if (['ArrowUp','Up'].includes(e.key)) sc.scrollTo({ top: 0, behavior: 'smooth' }); - if (['ArrowDown','Down'].includes(e.key)) sc.scrollTo({ top: sc.scrollHeight, behavior: 'smooth' }); - } else { - if (['ArrowUp','Up'].includes(e.key)) { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } - if (['ArrowDown','Down'].includes(e.key)) { - const h = Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight - ); - window.scrollTo({ top: h, behavior: 'smooth' }); - } + // Select All has no fixed cell anchor; preserve the complete selection. + if (e.shiftKey && !anchorCell && currentSelection.length > 1) { + return; } - const ref = anchorCell || currentSelection[0] || document.querySelector('td.selected, th.selected') || document.querySelector('td, th'); + const fixedAnchor = anchorCell || currentSelection.find(cell => { + const { row, col } = getCellCoords(cell); + return Number.isInteger(row) && Number.isInteger(col); + }) || table.querySelector('td[data-row][data-col], th[data-row][data-col]'); + const ref = e.shiftKey && anchorCell ? (rangeEndCell || fixedAnchor) : fixedAnchor; let target = null; if (ref) { const { row, col } = getCellCoords(ref); if (['ArrowLeft','Left','Home'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) + const rowCells = Array.from(table.querySelectorAll('td[data-row="'+row+'"], th[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const min = rowCells.reduce((acc, el) => Math.min(acc, parseInt(el.getAttribute('data-col'))), Infinity); target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === min) || ref; } else if (['ArrowRight','Right','End'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) + const rowCells = Array.from(table.querySelectorAll('td[data-row="'+row+'"], th[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const max = rowCells.reduce((acc, el) => Math.max(acc, parseInt(el.getAttribute('data-col'))), -1); target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === max) || ref; @@ -1470,39 +1693,41 @@ document.addEventListener('keydown', e => { } if (target) { - clearSelection(); - target.classList.add('selected'); - currentSelection.push(target); - anchorCell = target; - rangeEndCell = target; - persistState(); - target.focus({preventScroll:true}); - if (['ArrowUp','Up'].includes(e.key)) { - const topRow = getFirstDataRow(); - const below = table.querySelector('td[data-row="'+topRow+'"][data-col="'+getCellCoords(target).col+'"]'); - if (below) { - below.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } else { - target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } + if (e.shiftKey && fixedAnchor) { + anchorCell = fixedAnchor; + extendSelectionTo(target); } else { - target.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + clearSelection(); + target.classList.add('selected'); + currentSelection.push(target); + anchorCell = target; + rangeEndCell = target; } + persistState(); + (e.shiftKey ? anchorCell : target).focus({preventScroll:true}); + scheduleCellReveal(target); } } }, true); document.addEventListener('keydown', e => { + if (getElementTarget(e.target)?.closest('#filterStatus')) return; if (maybeHandleZoomShortcut(e)) { return; } const key = typeof e.key === 'string' ? e.key.toLowerCase() : ''; - if ((e.ctrlKey || e.metaKey) && key === 'f') { + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && key === 's') { + e.preventDefault(); + if (editingCell) { editingCell.blur(); } + vscode.postMessage({ type: 'save' }); + return; + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && key === 'f') { e.preventDefault(); openFindReplace(false); return; } - if ((e.ctrlKey || e.metaKey) && key === 'h') { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && key === 'h') { e.preventDefault(); openFindReplace(true); return; @@ -1522,14 +1747,15 @@ document.addEventListener('keydown', e => { closeFindReplace(); return; } - if (isFindWidgetTarget(e.target)) { + if (isGridControlTarget(e.target)) { return; } if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !editingCell) { e.preventDefault(); selectAllCells(); return; } - if ((e.ctrlKey || e.metaKey) && e.key === 'c' && currentSelection.length > 0) { - e.preventDefault(); copySelectionToClipboard(); return; + if ((e.ctrlKey || e.metaKey) && !editingCell && !isTextInputTarget(e.target) && + (key === 'c' || key === 'x') && currentSelection.length > 0) { + e.preventDefault(); copySelectionToClipboard(key === 'x'); return; } // Clear contents of selected cells when not editing @@ -1548,61 +1774,6 @@ document.addEventListener('keydown', e => { return; } - const isArrowKey = (k) => ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Up','Down','Left','Right','Home','End'].includes(k); - if (!editingCell && (e.ctrlKey || e.metaKey) && isArrowKey(e.key)) { - e.preventDefault(); - const sc = document.querySelector('.table-container'); - if (sc) { - if (['ArrowLeft','Left','Home'].includes(e.key)) sc.scrollTo({ left: 0, behavior: 'smooth' }); - if (['ArrowRight','Right','End'].includes(e.key)) sc.scrollTo({ left: sc.scrollWidth, behavior: 'smooth' }); - if (['ArrowUp','Up'].includes(e.key)) sc.scrollTo({ top: 0, behavior: 'smooth' }); - if (['ArrowDown','Down'].includes(e.key)) sc.scrollTo({ top: sc.scrollHeight, behavior: 'smooth' }); - } - - let refCell = anchorCell; - if (!refCell) { - refCell = currentSelection[0] || document.querySelector('td.selected, th.selected'); - } - let target = null; - if (refCell) { - const { row, col } = getCellCoords(refCell); - if (['ArrowLeft','Left','Home'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) - .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); - const min = rowCells.reduce((acc, el) => Math.min(acc, parseInt(el.getAttribute('data-col'))), Infinity); - target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === min) || refCell; - } else if (['ArrowRight','Right','End'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) - .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); - const max = rowCells.reduce((acc, el) => Math.max(acc, parseInt(el.getAttribute('data-col'))), -1); - target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === max) || refCell; - } else if (['ArrowUp','Up'].includes(e.key)) { - if (hasHeader) { - target = table.querySelector('th[data-row="0"][data-col="'+col+'"]') || refCell; - } else { - target = table.querySelector('td[data-row="0"][data-col="'+col+'"]') || refCell; - } - } else if (['ArrowDown','Down'].includes(e.key)) { - const colCells = Array.from(table.querySelectorAll('td[data-col="'+col+'"]')); - target = (colCells.length ? colCells[colCells.length - 1] : null) || refCell; - } - } - - if (target) { - clearSelection(); - target.classList.add('selected'); - currentSelection.push(target); - anchorCell = target; - rangeEndCell = target; - persistState(); - target.focus({preventScroll:true}); - target.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); - } - return; - } - if (!editingCell && e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); const refCell = anchorCell || getCellTarget(document.activeElement) || currentSelection[0] || document.querySelector('td.selected, th.selected'); @@ -1619,13 +1790,13 @@ document.addEventListener('keydown', e => { let targetRow = coords.row; let targetCol = coords.col + (isBackward ? -1 : 1); if (!isBackward && targetCol > maxCol) { - targetRow += 1; + targetRow = adjacentVisibleRow(targetRow, 1); targetCol = minCol; } else if (isBackward && targetCol < minCol) { if (targetRow <= firstDataRow) { return; } - targetRow -= 1; + targetRow = adjacentVisibleRow(targetRow, -1); targetCol = maxCol; } const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); @@ -1666,21 +1837,19 @@ document.addEventListener('keydown', e => { const { row, col } = getCellCoords(rangeEndCell || anchorCell); let targetRow = row, targetCol = col; switch(e.key){ - case 'ArrowUp': targetRow = row - 1; break; - case 'ArrowDown': targetRow = row + 1; break; + case 'ArrowUp': targetRow = adjacentVisibleRow(row, -1); break; + case 'ArrowDown': targetRow = adjacentVisibleRow(row, 1); break; case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } - if(targetRow < 0 || targetCol < 0) return; - const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); - const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); + if(targetRow < 0 || targetCol < (isRowIndexCell(anchorCell) ? -1 : 0)) return; + const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); if(nextCell){ e.preventDefault(); - rangeEndCell = nextCell; - selectRange(getCellCoords(anchorCell), getCellCoords(rangeEndCell)); + extendSelectionTo(nextCell); persistState(); anchorCell.focus({preventScroll:true}); - rangeEndCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(rangeEndCell); } return; } @@ -1689,14 +1858,14 @@ document.addEventListener('keydown', e => { const { row, col } = getCellCoords(anchorCell); let targetRow = row, targetCol = col; switch(e.key){ - case 'ArrowUp': targetRow = row - 1; break; - case 'ArrowDown': targetRow = row + 1; break; + case 'ArrowUp': targetRow = adjacentVisibleRow(row, -1); break; + case 'ArrowDown': targetRow = adjacentVisibleRow(row, 1); break; case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } if(targetRow < 0 || targetCol < 0) return; - const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); - const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); + const tag = (hasHeader && targetRow === headerSourceRow ? 'th' : 'td'); + const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); if(nextCell){ e.preventDefault(); clearSelection(); @@ -1706,7 +1875,7 @@ document.addEventListener('keydown', e => { rangeEndCell = nextCell; persistState(); nextCell.focus({preventScroll:true}); - nextCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(nextCell); } return; } @@ -1717,14 +1886,14 @@ document.addEventListener('keydown', e => { const { row, col } = getCellCoords(editingCell); let targetRow = row, targetCol = col; switch(e.key){ - case 'ArrowUp': targetRow = row - 1; break; - case 'ArrowDown': targetRow = row + 1; break; + case 'ArrowUp': targetRow = adjacentVisibleRow(row, -1); break; + case 'ArrowDown': targetRow = adjacentVisibleRow(row, 1); break; case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } if (targetRow >= 0 && targetCol >= 0) { - const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); - const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); + const tag = (hasHeader && targetRow === headerSourceRow ? 'th' : 'td'); + const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); if (nextCell) { const commitAndMove = () => { clearSelection(); @@ -1734,7 +1903,7 @@ document.addEventListener('keydown', e => { rangeEndCell = nextCell; persistState(); nextCell.focus({preventScroll:true}); - nextCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(nextCell); }; const cellRef = editingCell; cellRef && cellRef.blur(); @@ -1758,11 +1927,6 @@ document.addEventListener('keydown', e => { return; } - if (editingCell && ((e.ctrlKey || e.metaKey) && e.key === 's')) { - e.preventDefault(); - editingCell.blur(); - vscode.postMessage({ type: 'save' }); - } if (editingCell && e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { @@ -1773,7 +1937,7 @@ document.addEventListener('keydown', e => { } const { row, col } = getCellCoords(editingCell); editingCell.blur(); - const targetRow = row + 1; + const targetRow = adjacentVisibleRow(row, 1); // Editing Enter commits and moves selection down (no auto-edit). const nextCell = ensureRenderedCellByCoords(targetRow, col); if (nextCell) { @@ -1798,13 +1962,13 @@ document.addEventListener('keydown', e => { if (bounds) { targetCol = col + (isBackward ? -1 : 1); if (!isBackward && targetCol > bounds.maxCol) { - targetRow += 1; + targetRow = adjacentVisibleRow(targetRow, 1); targetCol = bounds.minCol; } else if (isBackward && targetCol < bounds.minCol) { if (targetRow <= firstDataRow) { canMove = false; } else { - targetRow -= 1; + targetRow = adjacentVisibleRow(targetRow, -1); targetCol = bounds.maxCol; } } @@ -1819,15 +1983,46 @@ document.addEventListener('keydown', e => { } } if (editingCell && e.key === 'Escape') { - e.preventDefault(); editingCell.innerText = originalCellValue; editingCell.blur(); + e.preventDefault(); + const { row, col } = getCellCoords(editingCell); + if (failedCellDrafts.has(`${row}:${col}`)) { + discardingFailedDraft = true; + editingCell.blur(); + discardingFailedDraft = false; + vscode.postMessage({ type: 'discardCellDraft', row, col }); + } else { + editingCell.textContent = originalCellValue; + editingCell.blur(); + } } if (!editingCell && e.key === 'Escape') { clearSelection(); } }); +const isTextInputTarget = target => { + const el = getElementTarget(target); + return !!(el && (el.closest('input, textarea, select') || el.isContentEditable)); +}; +const pasteFromClipboard = () => { + if (editingCell) return; + const anchor = getPasteAnchorCoords(); + if (!anchor) return; + vscode.postMessage({ + type: 'pasteFromClipboard', + anchorRow: anchor.row, + anchorCol: anchor.col, + selection: getDataSelectionBounds() || undefined + }); +}; +for (const type of ['copy', 'cut']) { + document.addEventListener(type, e => { + if (editingCell || isTextInputTarget(e.target) || isGridControlTarget(e.target)) return; + if (copySelectionToClipboard(type === 'cut')) e.preventDefault(); + }); +} document.addEventListener('paste', e => { - if (isFindWidgetTarget(e.target)) { + if (isTextInputTarget(e.target) || isGridControlTarget(e.target)) { return; } if (editingCell) { @@ -1858,10 +2053,10 @@ document.addEventListener('paste', e => { const selectAllCells = () => { clearSelection(); document.querySelectorAll('td, th').forEach(cell => { cell.classList.add('selected'); currentSelection.push(cell); }); }; -const setCursorToEnd = cell => { setTimeout(() => { +const setCursorToEnd = cell => { const range = document.createRange(); range.selectNodeContents(cell); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); -}, 10); }; +}; const setCursorToStart = cell => { setTimeout(() => { const range = document.createRange(); range.selectNodeContents(cell); range.collapse(true); @@ -1894,7 +2089,7 @@ const setSingleSelection = cell => { rangeEndCell = cell; persistState(); try { cell.focus({ preventScroll: true }); } catch { try { cell.focus(); } catch {} } - cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); + scheduleCellReveal(cell); }; const NEWLINE_SENTINEL_ATTR = 'data-csv-newline-sentinel'; @@ -1907,11 +2102,9 @@ const placeCaretBeforeSentinel = sentinel => { const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); - if (sentinel.firstChild) { - range.setStart(sentinel.firstChild, 0); - } else { - range.setStartBefore(sentinel); - } + // Keep typed text outside the disposable placeholder. A text-bearing span + // would absorb new input and delete that input when the placeholder is removed. + range.setStartBefore(sentinel); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); @@ -1919,9 +2112,8 @@ const placeCaretBeforeSentinel = sentinel => { const appendVisibleNewlineAtEnd = cell => { removeNewlineSentinels(cell); - const sentinel = document.createElement('span'); + const sentinel = document.createElement('br'); sentinel.setAttribute(NEWLINE_SENTINEL_ATTR, 'true'); - sentinel.textContent = '\u200B'; cell.appendChild(document.createTextNode('\n')); cell.appendChild(sentinel); placeCaretBeforeSentinel(sentinel); @@ -1944,9 +2136,8 @@ const insertNewlineAtCaret = cell => { const atEnd = range.collapsed && isRangeAtEndOfCell(cell, range); range.deleteContents(); if (atEnd) { - const sentinel = document.createElement('span'); + const sentinel = document.createElement('br'); sentinel.setAttribute(NEWLINE_SENTINEL_ATTR, 'true'); - sentinel.textContent = '\u200B'; const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode('\n')); fragment.appendChild(sentinel); @@ -1969,6 +2160,7 @@ const editCell = (cell, event, mode = 'detail') => { cell.classList.remove('selected'); originalCellValue = cell.textContent; editingCell = cell; + vscode.postMessage({ type: 'cellEditStarted' }); editMode = mode; cell.classList.add('editing'); cell.setAttribute('contenteditable', 'true'); @@ -1977,7 +2169,9 @@ const editCell = (cell, event, mode = 'detail') => { removeNewlineSentinels(cell); const value = cell.textContent; const coords = getCellCoords(cell); - vscode.postMessage({ type: 'editCell', row: coords.row, col: coords.col, value: value }); + if (!discardingFailedDraft) { + vscode.postMessage({ type: 'editCell', row: coords.row, col: coords.col, value: value }); + } cell.removeAttribute('contenteditable'); cell.classList.remove('editing'); editingCell = null; @@ -1996,6 +2190,7 @@ table.addEventListener('dblclick', e => { e.stopPropagation(); if (edge.axis === 'column') { resetColumnWidth(edge.index); + persistColumnSizes(); } else { resetRowHeight(edge.index); } @@ -2017,32 +2212,48 @@ table.addEventListener('dblclick', e => { editCell(target, e); }); -const copySelectionToClipboard = () => { - if (currentSelection.length === 0) return; - - // Only copy real data/header columns; skip serial index column (col === -1) - const coords = currentSelection - .map(cell => getCellCoords(cell)) - .filter(c => !isNaN(c.row) && !isNaN(c.col) && c.col >= 0); - if (coords.length === 0) return; - const minRow = Math.min(...coords.map(c => c.row)), maxRow = Math.max(...coords.map(c => c.row)); - const minCol = Math.min(...coords.map(c => c.col)), maxCol = Math.max(...coords.map(c => c.col)); - let csv = ''; - for(let r = minRow; r <= maxRow; r++){ - let rowVals = []; - for(let c = minCol; c <= maxCol; c++){ - const selector = (hasHeader && r === 0 ? 'th' : 'td') + '[data-row="'+r+'"][data-col="'+c+'"]'; - const cell = table.querySelector(selector); - rowVals.push(cell ? cell.innerText : ''); +const copySelectionToClipboard = (cut = false) => { + const bounds = getDataSelectionBounds(); + if (!bounds) return false; + const { minCol, maxCol } = bounds; + const realRowCount = Number(root.dataset.realrowcount); + // The append row is an editor affordance, never clipboard data. Consume an + // append-only gesture without a host write or native empty clipboard copy. + if (!Number.isSafeInteger(realRowCount) || realRowCount < 0) { return true; } + const matrix = []; + const rowIds = [...new Set(currentSelection.map(getDataCellCoords).filter(Boolean).map(coords => coords.row))] + .filter(row => row < realRowCount).sort((a, b) => a - b); + if (!rowIds.length) { return true; } + for (const row of rowIds) { + const values = []; + for (let col = minCol; col <= maxCol; col++) { + const cell = getRenderedCellByCoords(row, col); + values.push(cell ? cell.textContent : ''); } - csv += rowVals.join(CSV_SEPARATOR) + '\n'; + matrix.push(values); } - vscode.postMessage({ type: 'copyToClipboard', text: csv.trimEnd() }); + vscode.postMessage({ type: 'copyCells', matrix, rowIds, startCol: minCol, cut }); + return true; }; window.addEventListener('message', event => { const message = event.data; - if(message.type === 'focus'){ + if (message.type === 'prepareViewRefresh') { + if (editingCell) { editingCell.blur(); } + persistState(); + vscode.postMessage({ type: 'viewRefreshReady', requestId: message.requestId, session: filterSession, filterDraft: window.csvColumnFilters?.captureDraft() }); + } else if (message.type === 'columnFiltersResult') { + applyColumnFiltersResult(message); + } else if (message.type === 'frozenPanes') { + frozenRowCount = Number(message.rows); + frozenColumnCount = Number(message.columns); + window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount); + } else if (message.type === 'editCellFailed') { + failedCellDrafts.add(`${message.row}:${message.col}`); + const cell = table.querySelector(`[data-row="${message.row}"][data-col="${message.col}"]`); + if (message.retry && cell && !editingCell) { editCell(cell, undefined, 'detail'); } + } else if(message.type === 'focus'){ + if (document.activeElement?.closest?.('#filterStatus')) return; if (anchorCell) { try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} } } else { @@ -2051,6 +2262,7 @@ window.addEventListener('message', event => { } else if (message.type === 'chunkData') { const requestId = Number(message.requestId); const start = Number(message.start); + if (message.session !== filterSession || Number(message.filterEpoch) !== filterEpoch) return; if (remoteChunkRequestInFlight) { if (!Number.isInteger(requestId) || requestId !== remoteChunkRequestSeq) { return; @@ -2074,6 +2286,7 @@ window.addEventListener('message', event => { remoteNextChunkStart = -1; remoteHasMoreChunks = false; } + window.csvFrozenPanes?.schedule(); if (csvChunks.length && pendingEnsureTarget) { ensureTargetStep(); } else if (csvChunks.length && nearBottom()) { @@ -2082,8 +2295,9 @@ window.addEventListener('message', event => { } else if(message.type === 'updateCell'){ isUpdating = true; const { row, col, value, rendered } = message; + failedCellDrafts.delete(`${row}:${col}`); const cell = table.querySelector('td[data-row="'+row+'"][data-col="'+col+'"], th[data-row="'+row+'"][data-col="'+col+'"]'); - if (cell) { + if (cell && cell !== editingCell) { if (typeof rendered === 'string') { cell.innerHTML = rendered; } else { @@ -2095,6 +2309,7 @@ window.addEventListener('message', event => { scheduleFind(true); } } else if (message.type === 'pasteApplied') { + if (filterSnapshotPending || document.activeElement?.closest?.('#filterStatus')) return; const startRow = Number(message.startRow); const startCol = Number(message.startCol); const endRow = Number(message.endRow); @@ -2116,7 +2331,7 @@ window.addEventListener('message', event => { selectRange({ row: startRow, col: startCol }, { row: endRow, col: endCol }); persistState(); try { startCell.focus({ preventScroll: true }); } catch { try { startCell.focus(); } catch {} } - endCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(endCell); } else if (message.type === 'findMatchesResult') { if (!findReplaceState.open) { return; @@ -2162,10 +2377,11 @@ window.addEventListener('message', event => { // After initial restoreState, if there's a pending edit request, perform it const maybeResumePendingEdit = () => { + if (document.activeElement?.closest?.('#filterStatus')) return; try { const st = vscode.getState() || {}; if (st && typeof st.anchorRow === 'number' && typeof st.anchorCol === 'number' && st.pendingEdit === 'detail') { - const tag = (hasHeader && st.anchorRow === 0 ? 'th' : 'td'); + const tag = (hasHeader && st.anchorRow === headerSourceRow ? 'th' : 'td'); const sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); if (sel) { editCell(sel, undefined, 'detail'); @@ -2180,14 +2396,149 @@ const maybeResumePendingEdit = () => { // Try after load and after visibility/focus restores setTimeout(maybeResumePendingEdit, 0); -document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') setTimeout(maybeResumePendingEdit, 0); }); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + setTimeout(maybeResumePendingEdit, 0); + } else if (editingCell) { + editingCell.blur(); + } +}); +window.addEventListener('blur', () => { + if (editingCell) editingCell.blur(); +}); window.addEventListener('focus', () => { setTimeout(maybeResumePendingEdit, 0); }, { passive: true }); document.addEventListener('keydown', e => { - if (findReplaceState.open) { + if (findReplaceState.open || isGridControlTarget(e.target)) { return; } if(!editingCell && e.key === 'Escape'){ clearSelection(); } }); + +// A partial filter redraw keeps the delegated table and focused controls alive. +const initializeFrozenPanes = () => { + window.csvFrozenPanes?.dispose(); + if (typeof window.createFrozenPaneManager !== 'function') return; + window.csvFrozenPanes = window.createFrozenPaneManager({ + table, + firstRow: Number(root?.dataset?.firstrow || '0'), + realRowCount: Number(root?.dataset?.realrowcount || '0'), + rowOrder: visibleRowOrder, + topInset: () => document.getElementById('filterStatus')?.getBoundingClientRect().height || 0, + onNeedRows: () => loadNextChunk() + }); + window.csvFrozenPanes.update(frozenRowCount, frozenColumnCount); +}; +initializeFrozenPanes(); +window.addEventListener('csvChunkLoaded', () => window.csvFrozenPanes?.schedule()); +window.addEventListener('resize', () => window.csvFrozenPanes?.schedule()); +window.addEventListener('pagehide', () => window.csvFrozenPanes?.dispose(), { once: true }); +const invalidateFilterFind = () => { + clearTimeout(findDebounce); + latestFindRequestId = ++findRequestSeq; + pendingFindRequests.clear(); + clearFindHighlights(); + findMatches = []; + findMatchKeySet = new Set(); + currentMatchIndex = -1; + updateFindStatus(); + updateFindControls(); +}; +const setFilterPending = pending => { + filterSnapshotPending = pending; + table.toggleAttribute('inert', pending); + table.setAttribute('aria-busy', String(pending)); + if (pending) { + pendingCellReveal = null; + if (editingCell) editingCell.blur(); + invalidateFilterFind(); + } +}; +const submitColumnFilters = snapshot => { + if (editingCell) editingCell.blur(); + vscode.postMessage({ type: 'updateColumnFilters', ...snapshot, session: filterSession }); +}; +const applyColumnFiltersResult = message => { + if (message.session !== filterSession || !window.csvColumnFilters || message.revision !== window.csvColumnFilters.latestRevision) return; + // A newly started draft must reach the host before any replacement render. + if (editingCell) { + editingCell.blur(); + window.csvColumnFilters.retry(); + return; + } + if (!window.csvColumnFilters.receive(message)) return; + if (failedCellDrafts.size) { setFilterPending(false); return; } + const epoch = Number(message.filterEpoch); + if (typeof message.tableHtml === 'string' && Number.isSafeInteger(epoch) && epoch >= 0 && + (!(message.error || message.errors?.length) || epoch !== filterEpoch)) { + const parsed = document.createElement('template'); + parsed.innerHTML = message.tableHtml; + const replacement = parsed.content.querySelector('table'); + if (!replacement) return; + const priorAnchor = anchorCell ? getCellCoords(anchorCell) : null; + clearSelection(); + anchorCell = rangeEndCell = startCell = endCell = null; + isSelecting = false; + selectionMode = 'cell'; + resizeState = reorderState = null; + table.style.cursor = ''; + hideDragIndicator(); + pendingEnsureTarget = null; + pendingCellReveal = null; + invalidateFilterFind(); + chunkObserver?.disconnect(); + remoteChunkRequestInFlight = false; + remoteChunkRequestedStart = -1; + remoteChunkRequestSeq += 1; + filterEpoch = epoch; + table.replaceChildren(...Array.from(replacement.childNodes)); + hasHeader = !!table.tHead; + headerEnabled = message.headerEnabled === true; + root.dataset.headerenabled = headerEnabled ? '1' : '0'; + headerSourceRow = Number(message.firstVisibleRow); + root.dataset.firstrow = String(message.firstVisibleRow); + root.dataset.realrowcount = String(message.realRowCount); + visibleRowOrder.length = 0; + for (const row of Array.isArray(message.visibleRowIds) ? message.visibleRowIds : []) visibleRowOrder.push(row); + root.dataset.roworder = JSON.stringify(visibleRowOrder); + root.dataset.filtered = message.filters?.length ? '1' : '0'; + try { csvChunks = JSON.parse(message.chunksJson || '[]'); } catch { csvChunks = []; } + remoteNextChunkStart = Number.isInteger(message.nextChunkStart) ? message.nextChunkStart : -1; + remoteHasMoreChunks = !!message.hasRemoteChunks && remoteNextChunkStart >= 0; + root.dataset.nextchunkstart = remoteNextChunkStart >= 0 ? String(remoteNextChunkStart) : ''; + root.dataset.hasmorechunks = remoteHasMoreChunks ? '1' : '0'; + if (chunkTemplate) chunkTemplate.textContent = JSON.stringify(csvChunks); + const colors = document.getElementById('__csvColumnColors'); + if (colors && typeof message.colorCss === 'string') colors.textContent = message.colorCss; + if (priorAnchor) { + const cell = getRenderedCellByCoords(priorAnchor.row, priorAnchor.col); + if (cell) { cell.classList.add('selected'); currentSelection.push(cell); anchorCell = rangeEndCell = cell; } + } + applySizeStateToRenderedCells(); + initializeFrozenPanes(); + primeChunks(); + persistState(); + replaceAll.title = message.filters?.length ? 'Replace All in matching filtered rows' : 'Replace All'; + } + setFilterPending(false); + if (findReplaceState.open && findInput.value) runFind(true); +}; +const filterStatus = document.getElementById('filterStatus'); +if (filterStatus && typeof window.createColumnFilterControls === 'function') { + window.csvColumnFilters = window.createColumnFilterControls({ + root: filterStatus, + metadata: filterMetadata, + onChange: submitColumnFilters, + onActivity: () => vscode.postMessage({ type: 'filterDraftChanged', session: filterSession }), + onPending: setFilterPending, + onLayout: () => window.csvFrozenPanes?.schedule() + }); + if (typeof ResizeObserver === 'function') new ResizeObserver(() => window.csvFrozenPanes?.schedule()).observe(filterStatus); +} +for (const button of [replaceOne, replaceAll]) button.addEventListener('click', event => { + if (filterSnapshotPending) { event.preventDefault(); event.stopImmediatePropagation(); } +}, true); +// The host may be waiting to commit a draft while this webview initializes. +vscode.postMessage({ type: 'webviewReady' }); diff --git a/media/table-comparison.js b/media/table-comparison.js new file mode 100644 index 0000000..bbd0607 --- /dev/null +++ b/media/table-comparison.js @@ -0,0 +1,89 @@ +const vscode = acquireVsCodeApi(); +const original = document.getElementById('original'); +const modified = document.getElementById('modified'); +let page = 0; +let pendingFocus; +let alignFrame; +let synchronizing = false; +const expectedScroll = new WeakMap(); + +const alignRows = () => { + const left = Array.from(original.querySelectorAll('tbody tr')); + const right = Array.from(modified.querySelectorAll('tbody tr')); + [...left, ...right].forEach(row => { row.style.height = ''; }); + left.forEach((row, index) => { + if (!right[index]) return; + const height = Math.ceil(Math.max(row.getBoundingClientRect().height, right[index].getBoundingClientRect().height)); + row.style.height = right[index].style.height = `${height}px`; + }); + if (Number.isInteger(pendingFocus)) { + [original, modified].forEach(pane => { + const row = pane.querySelector(`[data-align="${pendingFocus}"]`); + if (row) { + row.classList.add('focused'); + const header = pane.querySelector('thead'); + pane.scrollTop = Math.max(0, pane.scrollTop + row.getBoundingClientRect().top - pane.getBoundingClientRect().top - pane.clientTop - (header?.getBoundingClientRect().height || 0)); + } + }); + pendingFocus = undefined; + } +}; +const queueAlignment = () => { + if (alignFrame) cancelAnimationFrame(alignFrame); + alignFrame = requestAnimationFrame(alignRows); +}; + +[[original, modified], [modified, original]].forEach(([source, target]) => { + source.addEventListener('scroll', () => { + if (synchronizing) return; + const expected = expectedScroll.get(source); + if (expected && Math.abs(source.scrollTop - expected.top) < 1 && Math.abs(source.scrollLeft - expected.left) < 1) { + expectedScroll.delete(source); + return; + } + expectedScroll.set(target, { top: source.scrollTop, left: source.scrollLeft }); + synchronizing = true; + target.scrollTop = source.scrollTop; + target.scrollLeft = source.scrollLeft; + synchronizing = false; + }, { passive: true }); +}); + +const buttons = ['previousPage', 'nextPage', 'previousChange', 'nextChange'].map(id => document.getElementById(id)); +const request = message => { + buttons.forEach(button => { button.disabled = true; }); + vscode.postMessage(message); +}; +buttons[0].addEventListener('click', () => request({ type: 'page', index: page - 1 })); +buttons[1].addEventListener('click', () => request({ type: 'page', index: page + 1 })); +buttons[2].addEventListener('click', () => request({ type: 'change', direction: -1 })); +buttons[3].addEventListener('click', () => request({ type: 'change', direction: 1 })); +window.addEventListener('message', event => { + const message = event.data; + if (message.type !== 'comparison') return; + original.innerHTML = message.originalHtml; + modified.innerHTML = message.modifiedHtml; + [original, modified].forEach(pane => { + const table = pane.querySelector('table'); + if (table) table.style.width = `${60 + Number(table.dataset.columns) * 180}px`; + pane.scrollTop = pane.scrollLeft = 0; + expectedScroll.delete(pane); + }); + page = message.page; + document.getElementById('status').textContent = message.status; + document.getElementById('error').textContent = message.error || ''; + document.getElementById('notice').textContent = message.notice || ''; + buttons[0].disabled = page <= 0; + buttons[1].disabled = page >= message.pageCount - 1; + buttons[2].disabled = !message.canPreviousChange; + buttons[3].disabled = !message.canNextChange; + pendingFocus = message.focusRecord; + queueAlignment(); +}); +if (typeof ResizeObserver === 'function') { + const observer = new ResizeObserver(queueAlignment); + observer.observe(original); + observer.observe(modified); +} +window.addEventListener('resize', queueAlignment); +request({ type: 'ready' }); diff --git a/package-lock.json b/package-lock.json index fe4da3a..fa94b49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "csv", - "version": "1.3.0", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "csv", - "version": "1.3.0", + "version": "1.4.2", "dependencies": { "font-list": "^1.5.1", - "papaparse": "^5.5.3" + "papaparse": "^5.5.3", + "re2js": "1.3.3" }, "devDependencies": { "@types/node": "^20.11.19", @@ -19,6 +20,8 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "iconv-lite": "^0.6.3", + "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" }, @@ -27,6 +30,27 @@ "vscode": "^1.70.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -238,6 +262,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -1806,6 +1945,34 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1824,6 +1991,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2869,6 +3043,19 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -3131,6 +3318,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -3208,6 +3402,84 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3741,6 +4013,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4250,6 +4529,12 @@ "node": ">=0.10.0" } }, + "node_modules/re2js": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-1.3.3.tgz", + "integrity": "sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==", + "license": "MIT" + }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -4406,6 +4691,13 @@ "node": "*" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4481,6 +4773,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/secretlint": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", @@ -4950,6 +5255,13 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", @@ -5086,6 +5398,26 @@ "url": "https://bevry.me/fund" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -5109,6 +5441,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5599,6 +5957,29 @@ "node": ">=4" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -5623,6 +6004,20 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5656,6 +6051,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -5672,6 +6089,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -5696,6 +6123,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 31d2bda..8b82a6c 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "csv", "displayName": "CSV", "publisher": "ReprEng", - "description": "Seamlessly Display and Edit CSVs", + "description": "View and edit CSV, TSV, TAB, and PSV files as tables.", "icon": "images/icon.jpg", "repository": { "type": "git", "url": "https://github.com/jonaraphael/csv.git" }, - "version": "1.3.0", + "version": "1.4.2", "engines": { "vscode": "^1.70.0", "node": ">=14" @@ -19,9 +19,15 @@ "Other" ], "activationEvents": [ + "onStartupFinished", "onLanguage:csv", "onLanguage:tsv", "onCommand:csv.toggleExtension", + "onCommand:csv.openLargeFilePreview", + "onCommand:csv.compareFilesAsTable", + "onCustomEditor:csv.largeFilePreview", + "onCommand:csv.toggleEditor", + "onCommand:csv.saveUntitled", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -29,7 +35,9 @@ "onCommand:csv.changeFontFamily", "onCommand:csv.changeIgnoreRows", "onCommand:csv.changeEncoding", - "onCommand:csv.toggleClickableLinks" + "onCommand:csv.toggleClickableLinks", + "onCommand:csv.changeFrozenRows", + "onCommand:csv.changeFrozenColumns" ], "main": "./out/extension.js", "contributes": { @@ -64,6 +72,25 @@ } ], "commands": [ + { + "command": "csv.openLargeFilePreview", + "title": "CSV: Open Large File Preview (Read-only)" + }, + { + "command": "csv.toggleEditor", + "title": "CSV: Switch Between Table and Text", + "icon": "$(arrow-swap)" + }, + { + "command": "csv.compareFilesAsTable", + "title": "CSV: Compare Files as Table" + }, + { + "command": "csv.saveUntitled", + "title": "CSV: Save Untitled Document As\u2026", + "icon": "$(save)", + "enablement": "activeWebviewPanelId == csv.untitledEditor" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -99,8 +126,48 @@ { "command": "csv.toggleClickableLinks", "title": "CSV: Toggle Clickable Links" + }, + { + "command": "csv.changeFrozenRows", + "title": "CSV: Freeze Leading Rows" + }, + { + "command": "csv.changeFrozenColumns", + "title": "CSV: Freeze Leading Columns" } ], + "menus": { + "editor/title": [ + { + "command": "csv.toggleEditor", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i) || (activeEditor == workbench.editors.textResourceEditor && resourceScheme == untitled && editorLangId =~ /^(csv|tsv)$/))", + "group": "navigation" + }, + { + "command": "csv.saveUntitled", + "when": "activeWebviewPanelId == csv.untitledEditor", + "group": "navigation@1" + } + ], + "commandPalette": [ + { + "command": "csv.saveUntitled", + "when": "activeWebviewPanelId == csv.untitledEditor" + } + ], + "explorer/context": [ + { + "command": "csv.compareFilesAsTable", + "when": "resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i && config.csv.enabled", + "group": "navigation" + }, + { + "command": "csv.openLargeFilePreview", + "when": "resourceScheme == file && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i && config.csv.enabled", + "group": "navigation" + } + ] + }, "configuration": { "type": "object", "title": "CSV Extension Configuration", @@ -113,7 +180,7 @@ "csv.fontFamily": { "type": "string", "default": "", - "description": "Font family used by the CSV custom editor. Leave empty to inherit ‘editor.fontFamily’.", + "description": "Font family used by the CSV custom editor. Leave empty to inherit \u2018editor.fontFamily\u2019.", "scope": "application" }, "csv.fontSize": { @@ -207,10 +274,36 @@ "default": 10, "minimum": 0, "description": "Soft max size in MB for opening files in CSV view. 0 disables the limit. When exceeded, CSV prompts to cancel, continue this time, or disable the limit." + }, + "csv.defaultIgnoreRows": { + "type": "integer", + "default": 0, + "minimum": 0, + "scope": "resource", + "description": "Default number of initial metadata rows to hide without deleting them. Per-file Hide First N Rows values override this default; leave that command blank to inherit again." + }, + "csv.largeFilePreviewRows": { + "type": "integer", + "default": 1000, + "minimum": 1, + "maximum": 10000, + "scope": "resource", + "description": "Maximum records per read-only Large File Preview page. Byte/cell safety limits can produce smaller pages." } } }, "customEditors": [ + { + "viewType": "csv.largeFilePreview", + "displayName": "CSV Large File Preview (Read-only)", + "priority": "option", + "selector": [ + { "filenamePattern": "*.csv" }, + { "filenamePattern": "*.tsv" }, + { "filenamePattern": "*.tab" }, + { "filenamePattern": "*.psv" } + ] + }, { "viewType": "csv.editor", "displayName": "CSV", @@ -240,7 +333,8 @@ }, "dependencies": { "font-list": "^1.5.1", - "papaparse": "^5.5.3" + "papaparse": "^5.5.3", + "re2js": "1.3.3" }, "devDependencies": { "@types/node": "^20.11.19", @@ -250,6 +344,8 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "iconv-lite": "^0.6.3", + "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" } diff --git a/scripts/editor-switch-smoke.cjs b/scripts/editor-switch-smoke.cjs new file mode 100644 index 0000000..3cd8964 --- /dev/null +++ b/scripts/editor-switch-smoke.cjs @@ -0,0 +1,99 @@ +// Run in an isolated VS Code development host using --extensionTestsPath. +const assert = require('assert').strict; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vscode = require('vscode'); +const { CsvEditorProvider } = require('../out/CsvEditorProvider'); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +function tabsFor(uri, column) { + return vscode.window.tabGroups.all.find(group => group.viewColumn === column).tabs.filter(tab => tab.input.uri?.toString() === uri.toString()); +} +function snapshotOtherGroups(column) { + return vscode.window.tabGroups.all.filter(group => group.viewColumn !== column).map(group => ({ + column: group.viewColumn, + tabs: group.tabs.map(tab => ({ uri: tab.input.uri?.toString(), type: tab.input.viewType || 'text', dirty: tab.isDirty })) + })); +} +exports.run = async function () { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-editor-switch-')); + await vscode.extensions.getExtension('ReprEng.csv').activate(); + try { + const unrelatedPath = path.join(directory, 'unrelated.csv'); + const otherPath = path.join(directory, 'other-group.csv'); + fs.writeFileSync(unrelatedPath, 'leave,this\nalone,1'); + fs.writeFileSync(otherPath, 'other,group\nkeep,2'); + const unrelated = vscode.Uri.file(unrelatedPath); + await vscode.commands.executeCommand('vscode.openWith', unrelated, 'default', { preview: false }); + const sourceColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; + await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.file(otherPath), 'default', { viewColumn: vscode.ViewColumn.Beside, preview: false }); + const otherSnapshot = snapshotOtherGroups(sourceColumn); + + for (const [suffix, separator] of [['csv', ','], ['tsv', '\t'], ['tab', '\t'], ['psv', '|']]) { + const filename = path.join(directory, `data.${suffix}`); + const contents = `name${separator}value\nfirst${separator}1`; + let expected = contents.replace('first', 'unsaved-first'); + fs.writeFileSync(filename, contents); + const uri = vscode.Uri.file(filename); + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { viewColumn: sourceColumn, preview: false }); + const edit = new vscode.WorkspaceEdit(); edit.insert(uri, new vscode.Position(1, 0), 'unsaved-'); + assert.equal(await vscode.workspace.applyEdit(edit), true); + for (let round = 0; round < 2; round++) { + for (const viewType of ['csv.editor', 'default']) { + await vscode.commands.executeCommand('csv.toggleEditor'); + const group = vscode.window.tabGroups.activeTabGroup; + const tabs = tabsFor(uri, sourceColumn); + assert.equal(group.viewColumn, sourceColumn); + assert.equal(tabs.length, 1, 'only one representation may remain after a switch'); + if (viewType === 'csv.editor') { assert.equal(group.activeTab.input.viewType, viewType); } + else { assert.ok(group.activeTab.input instanceof vscode.TabInputText); } + const live = vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString()); + assert.ok(live); assert.equal(live.isDirty, true); assert.equal(live.getText(), expected); + assert.equal(fs.readFileSync(filename, 'utf8'), contents, 'switching must not save'); + assert.deepEqual(snapshotOtherGroups(sourceColumn), otherSnapshot); + const untouched = tabsFor(unrelated, sourceColumn); + assert.equal(untouched.length, 1); assert.ok(untouched[0].input instanceof vscode.TabInputText, 'normalization must leave unrelated tabs alone'); + if (viewType === 'csv.editor' && round === 0) { + const controller = CsvEditorProvider.getActiveProvider(); + void controller.enqueueOperation(() => controller.updateDocument(1, 1, '1\nqueued draft')); + expected = `name${separator}value\nunsaved-first${separator}"1\nqueued draft"`; + } + } + } + if (suffix === 'csv') { + // Recreate the previous draft's two-view state, then ensure replacement + // reuses its existing counterpart and removes only the original source. + await vscode.commands.executeCommand('vscode.openWith', uri, 'csv.editor', { viewColumn: sourceColumn, preview: false }); + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { viewColumn: sourceColumn, preview: false }); + assert.equal(tabsFor(uri, sourceColumn).length, 2); + await vscode.commands.executeCommand('csv.toggleEditor'); + assert.equal(tabsFor(uri, sourceColumn).length, 1); + assert.equal(vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString()).getText(), expected); + assert.equal(fs.readFileSync(filename, 'utf8'), contents); + assert.deepEqual(snapshotOtherGroups(sourceColumn), otherSnapshot); + } + } + + const cancelPath = path.join(directory, 'cancel.csv'); + fs.writeFileSync(cancelPath, 'name,value\noriginal,1'); + const cancelUri = vscode.Uri.file(cancelPath); + await vscode.commands.executeCommand('vscode.openWith', cancelUri, 'default', { viewColumn: sourceColumn, preview: false }); + const edit = new vscode.WorkspaceEdit(); edit.insert(cancelUri, new vscode.Position(1, 0), 'unsaved-'); + await vscode.workspace.applyEdit(edit); + await vscode.workspace.getConfiguration('csv').update('maxFileSizeMB', 0.000001, vscode.ConfigurationTarget.Global); + // Return the same result as pressing Cancel in the large-file prompt; the + // extension-test host refuses modal dialogs instead of displaying them. + const showWarning = vscode.window.showWarningMessage; + vscode.window.showWarningMessage = async () => undefined; + try { await vscode.commands.executeCommand('csv.toggleEditor'); } + finally { vscode.window.showWarningMessage = showWarning; } + await sleep(200); + const live = vscode.workspace.textDocuments.find(document => document.uri.toString() === cancelUri.toString()); + assert.ok(live && live.isDirty, 'cancelled target initialization must retain the dirty owner'); + assert.equal(live.getText(), 'name,value\nunsaved-original,1'); + assert.equal(tabsFor(cancelUri, sourceColumn).length, 1); + assert.ok(tabsFor(cancelUri, sourceColumn)[0].input instanceof vscode.TabInputText, 'cancellation keeps the source'); + assert.equal(fs.readFileSync(cancelPath, 'utf8'), 'name,value\noriginal,1'); + console.log('CSV_SWITCH_SMOKE_OK: dirty CSV/TSV/TAB/PSV, queued multiline edits, one representation, counterpart reuse, unrelated tabs unchanged, other groups unchanged, target cancellation retained'); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}; diff --git a/scripts/untitled-editor-smoke.cjs b/scripts/untitled-editor-smoke.cjs new file mode 100644 index 0000000..e86888b --- /dev/null +++ b/scripts/untitled-editor-smoke.cjs @@ -0,0 +1,119 @@ +// Run in an isolated VS Code development host using --extensionTestsPath. +const assert = require('assert').strict; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vscode = require('vscode'); +const { CsvEditorProvider } = require('../out/CsvEditorProvider'); +const { getActiveUntitledCsvEditor } = require('../out/untitled-editors'); + +async function waitUntil(predicate, message) { + const deadline = Date.now() + 5000; + while (!predicate()) { + if (Date.now() > deadline) { throw new Error(message); } + await new Promise(resolve => setTimeout(resolve, 20)); + } +} +async function prepare(document, language, content) { + await vscode.window.showTextDocument(document, { preview: false }); + const edit = new vscode.WorkspaceEdit(); + edit.insert(document.uri, new vscode.Position(0, 0), content); + assert.equal(await vscode.workspace.applyEdit(edit), true); + const live = await vscode.languages.setTextDocumentLanguage(document, language); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'language mode should open the untitled table'); + assert.equal(live.getText(), content); + assert.ok(vscode.window.tabGroups.all.flatMap(group => group.tabs).some(tab => tab.input.uri?.toString() === live.uri.toString() && tab.isDirty), 'native source must retain its unsaved state'); + return live; +} + +exports.run = async function () { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-untitled-')); + await vscode.extensions.getExtension('ReprEng.csv').activate(); + try { + for (const language of ['csv', 'tsv']) { + const separator = language === 'tsv' ? '\t' : ','; + const filename = path.join(directory, `saved.${language}`); + const uri = vscode.Uri.file(filename).with({ scheme: 'untitled' }); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.languages.setTextDocumentLanguage(document, 'plaintext'); + const contents = `name${separator}value\nnew${separator}42`; + let live = await prepare(document, language, contents); + await new Promise(resolve => setTimeout(resolve, 200)); + await vscode.workspace.getConfiguration('csv').update('enabled', false, vscode.ConfigurationTarget.Global); + await waitUntil(() => vscode.window.activeTextEditor?.document.uri.toString() === live.uri.toString() && !getActiveUntitledCsvEditor(), 'disabling should reveal the native untitled owner'); + assert.equal(live.getText(), contents); + assert.equal(fs.existsSync(filename), false, 'disabling must not save'); + await vscode.workspace.getConfiguration('csv').update('enabled', true, vscode.ConfigurationTarget.Global); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'enabling should safely reopen associated-path untitled buffers'); + assert.equal(live.getText(), contents); + let controller = CsvEditorProvider.getActiveProvider(); + assert.ok(controller); + assert.equal(controller.getCurrentSeparator(), separator); + const oppositeLanguage = language === 'csv' ? 'tsv' : 'csv'; + live = await vscode.languages.setTextDocumentLanguage(live, oppositeLanguage); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live && controller.getCurrentSeparator() === (oppositeLanguage === 'tsv' ? '\t' : ','), 'native language rebind should use its logical extension'); + assert.equal(live.getText(), contents, 'language change must not convert source data'); + live = await vscode.languages.setTextDocumentLanguage(live, language); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live && controller.getCurrentSeparator() === separator, 'native language roundtrip should restore its logical extension'); + assert.equal(live.getText(), contents); + await controller.updateDocument(1, 1, '43'); + const expected = `name${separator}value\nnew${separator}43`; + assert.equal(live.getText(), expected); + const previousPanel = getActiveUntitledCsvEditor().panel; + await vscode.commands.executeCommand('csv.toggleEditor'); + assert.equal(vscode.window.activeTextEditor.document.uri.toString(), live.uri.toString()); + assert.equal(vscode.window.activeTextEditor.document.getText(), expected); + assert.equal(getActiveUntitledCsvEditor(), undefined, 'source switch closes only the standalone table'); + assert.equal(live.isClosed, false, 'native unsaved owner remains live'); + assert.equal(fs.existsSync(filename), false, 'source switching must not save'); + await new Promise(resolve => setTimeout(resolve, 50)); + assert.ok(vscode.window.activeTextEditor, 'explicit source switch must stay in source'); + await vscode.commands.executeCommand('csv.toggleEditor'); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'switch should recreate the untitled grid against the same owner'); + assert.notEqual(getActiveUntitledCsvEditor().panel, previousPanel); + controller = CsvEditorProvider.getActiveProvider(); + const savedExpected = `name${separator}value\nnew${separator}"43\nqueued"`; + void controller.enqueueOperation(() => controller.updateDocument(1, 1, '43\nqueued')); + await vscode.commands.executeCommand('csv.saveUntitled'); + await waitUntil(() => live.isClosed && fs.existsSync(filename) && fs.readFileSync(filename, 'utf8') === savedExpected && !CsvEditorProvider.editors.some(candidate => candidate.getDocumentUri().toString() === live.uri.toString()), 'registered native save should finish writing exact bytes and close the temporary grid'); + assert.equal(fs.readFileSync(filename, 'utf8'), savedExpected); + await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.file(filename), 'csv.editor', { preview: false }); + assert.equal(CsvEditorProvider.getActiveProvider().getCurrentSeparator(), separator); + } + + const document = await vscode.workspace.openTextDocument({ language: 'plaintext' }); + const live = await prepare(document, 'csv', 'name,value\ncancel,42'); + const panel = getActiveUntitledCsvEditor().panel; + const controller = CsvEditorProvider.getActiveProvider(); + // Exercise the cancellation result without opening an interactive native picker. + // Associated-path documents above exercise actual native saving and disk bytes. + const executeCommand = vscode.commands.executeCommand; + let cancelledSaveCalls = 0; + vscode.commands.executeCommand = (command, ...args) => { + if (command === 'workbench.action.files.save') { + cancelledSaveCalls += 1; + return Promise.resolve(); + } + return executeCommand(command, ...args); + }; + try { + await vscode.commands.executeCommand('csv.saveUntitled'); + await waitUntil(() => vscode.window.activeTextEditor?.document.uri.toString() === live.uri.toString() && !panel.active, 'registered Save As command should reveal the native owner and finish its view-state transition'); + await controller.enqueueOperation(() => {}); + } finally { + vscode.commands.executeCommand = executeCommand; + } + assert.equal(cancelledSaveCalls, 1); + assert.equal(live.isClosed, false); + assert.ok(vscode.window.tabGroups.all.flatMap(group => group.tabs).some(tab => tab.input.uri?.toString() === live.uri.toString() && tab.isDirty), 'cancel must retain the dirty native source'); + assert.equal(live.getText(), 'name,value\ncancel,42'); + await vscode.commands.executeCommand('csv.toggleEditor'); + await waitUntil(() => getActiveUntitledCsvEditor()?.panel === panel, 'cancelled Save As must retain and reuse the grid'); + panel.dispose(); + assert.equal(live.getText(), 'name,value\ncancel,42'); + assert.equal(CsvEditorProvider.currentActive, undefined); + console.log('CSV_UNTITLED_SMOKE_OK: pretyped CSV+TSV, language roundtrips, shared edits, source switching, disable/re-enable, registered Save As exact native saves, controlled Save As cancellation retained'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}; diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..965e5ff 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -1,6 +1,11 @@ import Papa from 'papaparse'; import * as vscode from 'vscode'; 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'; + +type FrozenPanes = { rows: number; columns: number }; type SeparatorMode = 'extension' | 'auto' | 'default'; type SeparatorSettings = { @@ -34,7 +39,9 @@ type PasteApplyResult = { plan: PastePlan; }; type ChunkRenderState = { - startAbs: number; + rowIds: number[]; + bodyStart: number; + virtualRow: number; allRows: string[][]; allRowsCount: number; chunkRows: number; @@ -47,6 +54,25 @@ type ChunkRenderState = { isDark: boolean; serialIndexWidthCh: number; }; +type FilterColumn = { column: number; label: string; dataType: FilterDataType; operators: ReturnType }; +type FilterDraft = { + session: string; + filterColumnsRevision: number; + rows: Array<{ column: number; operator: ColumnFilter['operator']; value: string; composing?: boolean }>; + columns: Array<{ column: number; label: string }>; + focus?: { + row: number; + field: 'column' | 'operator' | 'value' | 'remove'; + selectionStart?: number; + selectionEnd?: number; + selectionDirection?: 'forward' | 'backward' | 'none'; + }; +}; +type FilterRender = { + tableHtml: string; chunksJson: string; colorCss: string; nextChunkStart: number; hasRemoteChunks: boolean; + visibleRowIds: number[]; matchedRows: number; totalRows: number; filters: ColumnFilter[]; columns: FilterColumn[]; + firstVisibleRow: number; realRowCount: number; headerEnabled: boolean; chunkState: ChunkRenderState | undefined; +}; type ChunkResponse = { html: string; nextStart: number; @@ -61,14 +87,39 @@ class CsvEditorController { private static readonly DEFAULT_MAX_FILE_SIZE_MB = 10; private static readonly LARGE_FILE_CONTINUE_THIS_TIME = 'Continue This Time'; private static readonly LARGE_FILE_IGNORE_FOREVER = 'Ignore Forever'; + private static readonly encodingWarnings = new WeakSet(); + private static readonly DRAFT_COMMIT_TIMEOUT_MS = 5000; + private static nativeHandoffQueue: Promise = Promise.resolve(); private isUpdatingDocument = false; private isSaving = false; + private disposed = false; + private defaultEditorHandoff: Promise | undefined; + private pendingViewAction: (() => Promise) | undefined; + private pendingDocumentReference: { document: vscode.TextDocument; separator: string } | undefined; + private renderedSeparator: string | undefined; + private viewCommitRequested = false; + private viewCommitSequence = 0; + private viewCommitNeedsFreshAck = false; + private draftActivityRevision = 0; + private pendingClose = false; + private pendingOperation: Promise = Promise.resolve(); + private readonly failedCellEdits = new Map(); + private refreshAfterDraftCommit = false; + private readonly draftCommitWaiters = new Set<{ resolve: (success: boolean) => void; timer: ReturnType }>(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; private isDiffContext = false; + private currentHeader = false; private chunkRenderState: ChunkRenderState | undefined; + private currentFilterView: FilterRender | undefined; + private latestFilterRevision = 0; + private filterEpoch = 0; + private filterSession = ''; + private filterSessionUri = ''; + private filterColumnsRevision = 0; + private pendingFilterDraft: { uri: string; draft: FilterDraft } | undefined; constructor(private readonly context: vscode.ExtensionContext) {} @@ -80,6 +131,26 @@ class CsvEditorController { _token: vscode.CancellationToken ): Promise { this.document = document; + this.currentWebviewPanel = webviewPanel; + let changeDocumentSubscription: vscode.Disposable | undefined; + let saveDocumentSubscription: vscode.Disposable | undefined; + // Register before remote stat/prompt awaits: a closed panel stays closed. + webviewPanel.onDidDispose(() => { + this.disposed = true; + changeDocumentSubscription?.dispose(); + saveDocumentSubscription?.dispose(); + CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); + this.currentWebviewPanel = undefined; + this.pendingFilterDraft = undefined; + this.pendingViewAction = undefined; + this.pendingDocumentReference = undefined; + this.viewCommitRequested = false; + this.resolveDraftCommitWaiters(false); + if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; + } + }); + if (_token.isCancellationRequested) { return; } const config = vscode.workspace.getConfiguration('csv', this.document.uri); if (!config.get('enabled', true)) { @@ -89,17 +160,19 @@ class CsvEditorController { } const proceed = await this.confirmLargeFileOpen(config, webviewPanel, _token); - if (!proceed) { + if (!proceed || this.disposed || _token.isCancellationRequested || document.isClosed) { return; } - this.currentWebviewPanel = webviewPanel; + if (!vscode.workspace.getConfiguration('csv', document.uri).get('enabled', true)) { + await this.openWithDefaultEditorAndClose(webviewPanel, document.uri); + return; + } CsvEditorProvider.editors.push(this); webviewPanel.webview.options = { enableScripts: true, - // Use file path for compatibility with older VS Code types (no Uri.joinPath) - localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'media'))] + localResourceRoots: [this.getMediaUri()] }; this.refreshDiffContext(webviewPanel); @@ -108,23 +181,82 @@ class CsvEditorController { if (webviewPanel.active) { CsvEditorProvider.currentActive = this; } + void this.warnAboutPossibleDecoding().catch(error => { + console.warn('CSV: Encoding warning action failed', error); + }); webviewPanel.webview.postMessage({ type: 'focus' }); + let wasVisible = webviewPanel.visible; webviewPanel.onDidChangeViewState(e => { + const becameVisible = e.webviewPanel.visible && !wasVisible; + wasVisible = e.webviewPanel.visible; if (e.webviewPanel.active) { const diffChanged = this.refreshDiffContext(e.webviewPanel); - if (diffChanged) { - this.updateWebviewContent(); + if (diffChanged || becameVisible) { + // A hidden webview can be recreated from its original HTML snapshot. + // Rebuild from the current (possibly unsaved) document after pending edits. + void this.enqueueOperation(() => this.updateWebviewContent()); } e.webviewPanel.webview.postMessage({ type: 'focus' }); CsvEditorProvider.currentActive = this; + } else if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; } }); - webviewPanel.webview.onDidReceiveMessage(async e => { + webviewPanel.webview.onDidReceiveMessage(e => { + if (e.type === 'filterDraftChanged') { + if (e.session === this.filterSession) { this.draftActivityRevision += 1; } + return; + } + if (e.type === 'updateColumnFilters' && e.session === this.filterSession && Number.isSafeInteger(e.revision) && e.revision > this.latestFilterRevision) { + this.latestFilterRevision = e.revision; + } + const mutatesTable = ['replaceCells', 'pasteCells', 'insertColumn', 'insertColumns', 'deleteColumn', 'deleteColumns', + 'insertRow', 'insertRows', 'deleteRow', 'deleteRows', 'reorderColumns', 'reorderRows', 'sortColumn', 'pasteFromClipboard'].includes(e.type) || (e.type === 'copyCells' && e.cut === true); + // Observe new drafts immediately, even while an earlier queued edit awaits + // applyEdit. An ACK received before that activity cannot authorize reload. + if (mutatesTable || ['cellEditStarted', 'editCell', 'discardCellDraft'].includes(e.type)) { + this.draftActivityRevision += 1; + } + const receivedRevision = this.draftActivityRevision; + return this.enqueueOperation(async () => { + if (mutatesTable && this.failedCellEdits.size) { + const failed = this.failedCellEdits.values().next().value!; + void webviewPanel.webview.postMessage({ type: 'editCellFailed', row: failed.row, col: failed.col, retry: true }); + void vscode.window.showErrorMessage('CSV: Retry the failed cell edit or press Escape to discard it before changing the table.'); + return; + } switch (e.type) { + case 'webviewReady': { + if (this.viewCommitRequested) { + this.postViewCommitRequest(); + } + break; + } + case 'viewRefreshReady': { + if (e.session !== this.filterSession) { break; } + if (this.viewCommitRequested && e.requestId !== this.viewCommitSequence) { + break; + } + if (!this.viewCommitRequested && e.requestId !== undefined) { + break; + } + if (this.viewCommitRequested && (this.viewCommitNeedsFreshAck || receivedRevision !== this.draftActivityRevision)) { + this.viewCommitNeedsFreshAck = false; + this.viewCommitSequence += 1; + this.postViewCommitRequest(); + break; + } + this.captureFilterDraft(e.filterDraft); + await this.finishViewCommit(); + break; + } case 'editCell': - this.updateDocument(e.row, e.col, e.value); + await this.updateDocument(e.row, e.col, e.value); + break; + case 'discardCellDraft': + this.discardFailedCellDraft(e.row, e.col); break; case 'replaceCells': await this.replaceCells(e.replacements); @@ -133,17 +265,54 @@ class CsvEditorController { await this.pasteCells(e.text, e.anchorRow, e.anchorCol, e.selection); break; case 'requestChunk': - await this.requestChunk(e.start, e.requestId); + await this.requestChunk(e.start, e.requestId, e.filterEpoch, e.session); break; case 'findMatches': await this.findMatches(e.requestId, e.query, e.options); break; case 'save': - await this.handleSave(); + if (this.viewCommitRequested) { + this.saveAfterCommit(); + } else { + await this.handleSave(); + } + break; + case 'updateColumnFilters': + await this.updateColumnFilters(e.revision, e.filters, e.session); break; - case 'copyToClipboard': - await vscode.env.clipboard.writeText(e.text); - console.log('CSV: Copied to clipboard'); + case 'freezePanesChanged': + await this.setFrozenPanes({ rows: e.rows, columns: e.columns }); + break; + case 'columnSizesChanged': + await CsvEditorProvider.setColumnSizesForUri(this.context, this.document.uri, e.columnSizes); + break; + case 'setHeader': { + if (typeof e.enabled === 'boolean') { + const uri = this.document.uri; + await CsvEditorProvider.setHeaderForUri(this.context, uri, e.enabled); + CsvEditorProvider.editors + .filter(editor => editor.getDocumentUri().toString() === uri.toString()) + .forEach(editor => editor.refresh()); + } + break; + } + case 'setSerialIndex': { + if (typeof e.enabled === 'boolean') { + const uri = this.getDocumentUri(); + if (CsvEditorProvider.getSerialIndexForUri(this.context, uri) !== e.enabled) { + await CsvEditorProvider.setSerialIndexForUri(this.context, uri, e.enabled); + CsvEditorProvider.editors + .filter(editor => editor.getDocumentUri().toString() === uri.toString()) + .forEach(editor => editor.refreshViewSafely()); + } + } + break; + } + case 'copyCells': + await this.copyCells(e.matrix, e.rowIds, e.startCol, e.cut === true); + break; + case 'pasteFromClipboard': + await this.pasteCells(await vscode.env.clipboard.readText(), e.anchorRow, e.anchorCol, e.selection); break; case 'insertColumn': await this.insertColumn(e.index); @@ -182,9 +351,10 @@ class CsvEditorController { await this.openLinkExternally(e.url); break; } + }); }); - const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { + changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( e.document.uri.toString() === document.uri.toString() && !this.isUpdatingDocument && @@ -194,11 +364,38 @@ class CsvEditorController { } }); - webviewPanel.onDidDispose(() => { - changeDocumentSubscription.dispose(); - CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); - this.currentWebviewPanel = undefined; + saveDocumentSubscription = vscode.workspace.onWillSaveTextDocument(e => { + if (e.document.uri.toString() === document.uri.toString() && !this.isSaving) { + // Native File > Save can run while the webview's blur edit is applying. + // The custom save command is already in this queue and must not await itself. + e.waitUntil(this.pendingOperation); + } + }); + } + + 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 => { + console.error('CSV: Editor operation failed', error); }); + return this.pendingOperation; + } + + private async warnAboutPossibleDecoding(): Promise { + if (this.document.uri.scheme === 'untitled' || !this.document.getText().includes('\uFFFD') || CsvEditorController.encodingWarnings.has(this.document)) { + return; + } + CsvEditorController.encodingWarnings.add(this.document); + const action = 'Change File Encoding'; + const selected = await vscode.window.showWarningMessage( + 'CSV: This file contains replacement characters (�), which can indicate a decoding mismatch. Check the source encoding before saving.', + action + ); + if (selected === action && !this.disposed) { + // Bind the action to this document even if another tab becomes active. + await vscode.commands.executeCommand('csv.changeEncoding', this.document.uri); + } } private getMaxFileSizeLimitMb(config: vscode.WorkspaceConfiguration): number { @@ -227,17 +424,118 @@ class CsvEditorController { return (fileSizeBytes / CsvEditorController.BYTES_PER_MB).toFixed(1); } - private async openWithDefaultEditorAndClose(webviewPanel: vscode.WebviewPanel, uri: vscode.Uri): Promise { - try { - const opts: any = { - viewColumn: webviewPanel.viewColumn, - preserveFocus: !webviewPanel.active, - preview: webviewPanel.active ? webviewPanel.active : false + private captureOtherEditorOwner(uri: vscode.Uri, viewColumn: vscode.ViewColumn | undefined): (() => Promise) | undefined { + const group = vscode.window.tabGroups.activeTabGroup; + if (!group) { return; } + const tab = group.activeTab; + const input: any = tab?.input; + const stillOpen = () => vscode.window.tabGroups.all.includes(group) && !!tab && group.tabs.includes(tab); + const activeController = CsvEditorProvider.editors.find(editor => editor !== this && editor.isActive() && editor.getViewColumn() === group.viewColumn); + const panel = activeController?.currentWebviewPanel; + if (panel) { + const document = activeController.document; + return async () => { + if (!vscode.window.tabGroups.all.includes(group)) { return; } + if (activeController.currentWebviewPanel === panel && panel.viewColumn === group.viewColumn && stillOpen()) { + panel.reveal(group.viewColumn, false); + } else if (!activeController.currentWebviewPanel && !document.isClosed && group.tabs.some(candidate => { + const native: any = candidate.input; + return !native?.viewType && native?.uri?.toString() === document.uri.toString(); + })) { + // Another Off handoff can replace the captured grid first. Reuse + // its native owner only while that owner still has a live tab here. + await vscode.window.showTextDocument(document, { viewColumn: group.viewColumn, preview: false }); + } }; - await vscode.commands.executeCommand('vscode.openWith', uri, 'default', opts); - } finally { - try { webviewPanel.dispose(); } catch {} } + const activeUri: vscode.Uri | undefined = input?.uri; + if (!activeUri || (group.viewColumn === viewColumn && activeUri.toString() === uri.toString())) { return; } + if (input.viewType === CsvEditorProvider.viewType) { return; } + if (input.viewType) { + return async () => { if (stillOpen()) { await vscode.commands.executeCommand('vscode.openWith', activeUri, input.viewType, { viewColumn: group.viewColumn, preserveFocus: false }); } }; + } + const editor = vscode.window.visibleTextEditors.find(candidate => candidate.viewColumn === group.viewColumn && candidate.document.uri.toString() === activeUri.toString()); + if (editor) { + return async () => { + if (!stillOpen()) { return; } + // The owner may have acquired its standalone table while the saved-file + // handoff was pending. Reveal that live view instead of promoting text. + const controller = CsvEditorProvider.editors.find(candidate => candidate !== this && candidate.getViewColumn() === group.viewColumn && candidate.getDocumentUri().toString() === activeUri.toString()); + if (controller?.currentWebviewPanel) { controller.currentWebviewPanel.reveal(group.viewColumn, false); } + else if (!editor.document.isClosed) { await vscode.window.showTextDocument(editor.document, { viewColumn: group.viewColumn, preview: false }); } + }; + } + } + + private async openWithDefaultEditorAndClose(webviewPanel: vscode.WebviewPanel, uri: vscode.Uri, reason?: 'disabled' | 'language'): Promise { + if (this.disposed) { return; } + if (!this.defaultEditorHandoff) { + const authorization = reason ? { + activity: this.draftActivityRevision, document: this.document, version: this.document.version, + language: this.document.languageId, session: this.filterSession, filterRevision: this.latestFilterRevision, + columnsRevision: this.filterColumnsRevision + } : undefined; + let reprepare = false; + const pending = CsvEditorController.nativeHandoffQueue.then(async () => { + if (this.disposed || this.document.isClosed) { return; } + const supported = uri.scheme !== 'untitled' || this.document.languageId === 'csv' || this.document.languageId === 'tsv'; + if (supported && CsvEditorProvider.editors.includes(this) && vscode.workspace.getConfiguration('csv', uri).get('enabled', true)) { return; } + if (authorization && (authorization.activity !== this.draftActivityRevision || authorization.document !== this.document || + authorization.version !== this.document.version || authorization.language !== this.document.languageId || + authorization.session !== this.filterSession || authorization.filterRevision !== this.latestFilterRevision || + authorization.columnsRevision !== this.filterColumnsRevision || this.pendingDocumentReference)) { + // A different handoff held the native queue after this view's ACK. + // Release this slot and request a new ACK without blocking its own + // message queue, where the final cell/filter edits must be applied. + reprepare = true; + return; + } + let restoreOwner = this.captureOtherEditorOwner(uri, webviewPanel.viewColumn); + const focusSubscription = vscode.window.onDidChangeActiveTextEditor(() => { + const next = this.captureOtherEditorOwner(uri, webviewPanel.viewColumn); + if (next) { restoreOwner = next; } + }); + const wasActive = webviewPanel.active; + try { + const opts = { viewColumn: webviewPanel.viewColumn, preserveFocus: !wasActive }; + if (uri.scheme === 'untitled') { + await vscode.window.showTextDocument(this.document, { ...opts, preview: false }); + } else { + const existing = vscode.window.visibleTextEditors.find(editor => + editor.document.uri.toString() === uri.toString() && editor.viewColumn === webviewPanel.viewColumn); + if (!existing) { + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', opts); + } + } + } finally { + focusSubscription.dispose(); + const supported = uri.scheme !== 'untitled' || this.document.languageId === 'csv' || this.document.languageId === 'tsv'; + const retainTable = supported && !this.disposed && CsvEditorProvider.editors.includes(this) && + vscode.workspace.getConfiguration('csv', uri).get('enabled', true); + const group = vscode.window.tabGroups.activeTabGroup; + const input: any = group?.activeTab?.input; + const nativeHandoffIsActive = group?.viewColumn === webviewPanel.viewColumn && !input?.viewType && input?.uri?.toString() === uri.toString(); + if (!retainTable) { try { webviewPanel.dispose(); } catch {} } + // Only undo activation caused by this handoff. A later user choice in + // another tab/group always keeps focus and its live document owner. + if (nativeHandoffIsActive) { + if (restoreOwner) { await restoreOwner(); } + else if (retainTable && wasActive) { webviewPanel.reveal(webviewPanel.viewColumn, false); } + } + } + }); + // Only native opens/disposal/restoration serialize; draft ACKs remain + // independent, so a failed edit in one file cannot block other files. + CsvEditorController.nativeHandoffQueue = pending.then(() => {}, () => {}); + this.defaultEditorHandoff = pending; + void pending.then(() => { + if (this.defaultEditorHandoff === pending) { this.defaultEditorHandoff = undefined; } + if (reprepare && !this.disposed && reason) { this.revealSourceAfterCommit(reason); } + }, () => { + if (this.defaultEditorHandoff === pending) { this.defaultEditorHandoff = undefined; } + }); + } + await this.defaultEditorHandoff; } private async confirmLargeFileOpen( @@ -245,6 +543,9 @@ class CsvEditorController { webviewPanel: vscode.WebviewPanel, token: vscode.CancellationToken ): Promise { + if (this.document.uri.scheme === 'untitled') { + return true; + } const maxFileSizeMB = this.getMaxFileSizeLimitMb(config); if (maxFileSizeMB <= 0) { return true; @@ -293,19 +594,176 @@ class CsvEditorController { } public refresh() { + if (this.disposed) { return; } const config = vscode.workspace.getConfiguration('csv', this.document.uri); - if (!config.get('enabled', true)) { - this.currentWebviewPanel?.dispose(); - vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); + const enabled = config.get('enabled', true); + if (this.failedCellEdits.size) { + this.refreshAfterDraftCommit = true; + if (!enabled) { + void vscode.window.showErrorMessage('CSV: Retry the failed cell edit or press Escape to discard it before disabling the table view.'); + } + return; + } + if (!enabled) { + this.revealSourceAfterCommit(); } else { - if (this.currentWebviewPanel) { + if (this.defaultEditorHandoff) { return; } + if (this.viewCommitRequested || this.failedCellEdits.size) { + this.refreshAfterDraftCommit = true; + return; + } + if (this.currentWebviewPanel && !this.viewCommitRequested && !this.failedCellEdits.size) { this.forceReload(); } } } + public getViewColumn(): vscode.ViewColumn | undefined { + return this.currentWebviewPanel?.viewColumn; + } + + public commitPendingDraft(): Promise { + const panel = this.currentWebviewPanel; + if (!panel) { + return Promise.resolve(false); + } + return new Promise(resolve => { + const waiter = { + resolve, + timer: setTimeout(() => { + this.draftCommitWaiters.delete(waiter); + resolve(false); + }, CsvEditorController.DRAFT_COMMIT_TIMEOUT_MS) + }; + this.draftCommitWaiters.add(waiter); + const alreadyRequested = this.viewCommitRequested; + this.requestViewCommit(); + if (alreadyRequested && panel.visible) { + this.postViewCommitRequest(); + } + }); + } + + private resolveDraftCommitWaiters(success: boolean): void { + for (const waiter of this.draftCommitWaiters) { + clearTimeout(waiter.timer); + waiter.resolve(success); + } + this.draftCommitWaiters.clear(); + } + + public revealSourceAfterCommit(reason: 'disabled' | 'language' = 'disabled'): void { + const panel = this.currentWebviewPanel; + if (!panel) { + return; + } + // A deliberate close takes priority over a pending Save As or re-render. + this.pendingClose = true; + this.pendingViewAction = async () => { + const enabled = vscode.workspace.getConfiguration('csv', this.document.uri).get('enabled', true); + const supportedLanguage = this.document.languageId === 'csv' || this.document.languageId === 'tsv'; + if (enabled && (reason === 'disabled' || supportedLanguage)) { + // A quick re-enable/language roundtrip still commits the active draft. + this.updateWebviewContent(); + return; + } + await this.openWithDefaultEditorAndClose(panel, this.document.uri, reason); + }; + this.requestViewCommit(); + } + + public saveUntitledAfterCommit(): void { + if (this.document.uri.scheme === 'untitled') { + this.saveAfterCommit(); + } + } + + private saveAfterCommit(): void { + if (!this.currentWebviewPanel || this.pendingClose) { + return; + } + this.pendingViewAction = async () => { + await this.handleSave(); + this.updateWebviewContent(); + }; + this.requestViewCommit(); + } + + private postViewCommitRequest(): void { + const panel = this.currentWebviewPanel; + if (!panel) { + this.resolveDraftCommitWaiters(false); + return; + } + try { + void Promise.resolve(panel.webview.postMessage({ + type: 'prepareViewRefresh', requestId: this.viewCommitSequence + })).then(delivered => { + if (!delivered) { this.resolveDraftCommitWaiters(false); } + }, () => this.resolveDraftCommitWaiters(false)); + } catch { + this.resolveDraftCommitWaiters(false); + } + } + + private requestViewCommit(): void { + const panel = this.currentWebviewPanel; + if (!panel || this.viewCommitRequested) { + return; + } + this.viewCommitRequested = true; + this.viewCommitNeedsFreshAck = false; + this.viewCommitSequence += 1; + if (panel.visible) { + // The final edit arrives before the acknowledgment in the ordered queue. + // webviewReady resends this request if the first script is still loading. + this.postViewCommitRequest(); + } else { + void this.enqueueOperation(() => this.finishViewCommit()); + } + } + + private requestRecoveryRefresh(): void { + this.refreshAfterDraftCommit = true; + if (this.viewCommitRequested) { + // A recovery cannot reuse the snapshot taken before its queued edit. + this.viewCommitNeedsFreshAck = true; + return; + } + // The acknowledgment itself runs on the ordered queue, so never await it here. + this.requestViewCommit(); + } + + private async finishViewCommit(): Promise { + this.viewCommitRequested = false; + if (this.failedCellEdits.size) { + // Keep the live DOM and any pending action until its failed cell can be retried. + this.resolveDraftCommitWaiters(false); + return; + } + const hadWaiters = this.draftCommitWaiters.size > 0; + if (this.pendingDocumentReference) { + this.document = this.pendingDocumentReference.document; + this.pendingDocumentReference = undefined; + this.separatorCache = undefined; + } + const action = this.pendingViewAction; + this.pendingViewAction = undefined; + this.pendingClose = false; + const refreshNeeded = this.refreshAfterDraftCommit; + this.refreshAfterDraftCommit = false; + if (action) { + await action(); + } else if (!hadWaiters || refreshNeeded) { + this.updateWebviewContent(); + } + // An awaited action can leave a newer request pending. Its waiters need + // that request's own ACK, even when this older action just completed. + if (!this.viewCommitRequested) { this.resolveDraftCommitWaiters(!!this.currentWebviewPanel && !this.failedCellEdits.size); } + } + private forceReload() { - if (!this.currentWebviewPanel) return; + if (!this.currentWebviewPanel || this.failedCellEdits.size) return; const panel = this.currentWebviewPanel; // First, blank the DOM to ensure a full script/style reinit on next set panel.webview.html = ''; @@ -386,6 +844,198 @@ class CsvEditorController { ); } + private getFilterColumns(data: string[][]): FilterColumn[] { + const offset = Math.min(Math.max(0, this.getHiddenRows()), data.length); + const header = this.getEffectiveHeader(data, offset); + const body = data.slice(offset + (header ? 1 : 0)); + const count = Math.max(1, data.reduce((max, row) => Math.max(max, row.length), 0)); + return Array.from({ length: count }, (_, column) => { + const values = body.map(row => row[column] ?? ''); + const inferred = this.estimateColumnDataType(values); + let dataType: FilterDataType = inferred === 'integer' || inferred === 'float' ? 'number' + : inferred === 'date' || inferred === 'boolean' ? inferred : 'string'; + if (inferred === 'string') { + const nonblank = values.filter(value => value.trim() !== ''); + if (nonblank.length && nonblank.every(value => parseFilterDate(value) !== undefined)) { + dataType = 'date'; + } + } + return { column, label: (header ? data[offset]?.[column] : '') || `Column ${column + 1}`, dataType, operators: getFilterOperatorChoices(dataType) }; + }); + } + + private filterResponse(): Record { + if (!this.currentFilterView) { return { filterEpoch: this.filterEpoch }; } + const { chunkState, ...payload } = this.currentFilterView; + return { ...payload, filterEpoch: this.filterEpoch }; + } + + private async updateColumnFilters(rawRevision: unknown, rawFilters: unknown, session: unknown): Promise { + const panel = this.currentWebviewPanel; + if (!panel || session !== this.filterSession) { return; } + const reply = (extra: Record) => { + if (session !== this.filterSession || panel !== this.currentWebviewPanel) { return; } + void panel.webview.postMessage({ type: 'columnFiltersResult', session: this.filterSession, revision: rawRevision, ...this.filterResponse(), ...extra }); + }; + if (typeof rawRevision !== 'number' || !Number.isSafeInteger(rawRevision) || rawRevision <= 0) { + reply({ error: 'Filter revisions must be positive safe integers.' }); + return; + } + if (rawRevision !== this.latestFilterRevision || rawRevision <= this.filterEpoch) { return; } + if (this.failedCellEdits.size) { + reply({ error: 'Retry the failed cell edit before changing filters.' }); + return; + } + if (this.pendingDocumentReference || this.viewCommitRequested) { + reply({ error: 'Finish the current editor change before changing filters.' }); + return; + } + if (!Array.isArray(rawFilters) || rawFilters.length > MAX_COLUMN_FILTERS) { + reply({ error: `Provide at most ${MAX_COLUMN_FILTERS} filter conditions.` }); + return; + } + const data = this.trimTrailingEmptyRows(Papa.parse(this.document.getText(), { delimiter: this.getSeparator() }).data); + const columns = this.getFilterColumns(data); + const filters: ColumnFilter[] = []; + const errors: Array<{ index: number; message: string }> = []; + rawFilters.forEach((raw: unknown, index: number) => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push({ index, message: 'Provide a valid filter condition.' }); + return; + } + const item = raw as Record; + if (Object.keys(item).some(key => !['column', 'operator', 'value', 'dataType'].includes(key)) || + typeof item.column !== 'number' || !Number.isSafeInteger(item.column) || item.column < 0 || item.column >= columns.length || + typeof item.operator !== 'string' || typeof item.value !== 'string' || item.value.length > MAX_FILTER_VALUE_LENGTH || + (item.dataType !== undefined && (typeof item.dataType !== 'string' || !['string', 'number', 'date', 'boolean'].includes(item.dataType)))) { + errors.push({ index, message: 'Provide a valid column, operator, type and filter value.' }); + return; + } + const column = columns[item.column]; + const choice = column.operators.find(operator => operator.operator === item.operator); + if (!choice) { + errors.push({ index, message: `Choose an operator supported by this ${column.dataType} column.` }); + return; + } + const typed = !['contains', 'notContains', 'regex', 'empty', 'notEmpty'].includes(choice.operator); + const dataType = typed && column.dataType !== 'string' ? column.dataType : undefined; + // Empty text boxes remain local drafts, including incomplete typed queries. + if (choice.operator !== 'empty' && choice.operator !== 'notEmpty' && item.value.length === 0) { return; } + const error = validateColumnFilterInput(choice.operator, item.value, dataType); + if (error) { errors.push({ index, message: error }); return; } + filters.push({ column: item.column, operator: choice.operator, value: item.value, ...(dataType ? { dataType } : {}) }); + }); + if (errors.length) { reply({ errors, columns }); return; } + const view = this.buildFilterView(data, filters); + if (session !== this.filterSession || rawRevision !== this.latestFilterRevision) { return; } + const previous = CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri); + try { + await CsvEditorProvider.setColumnFiltersForUri(this.context, this.document.uri, filters); + if (session !== this.filterSession || rawRevision !== this.latestFilterRevision) { + await CsvEditorProvider.setColumnFiltersForUri(this.context, this.document.uri, previous); + return; + } + } catch { + reply({ error: 'The filter conditions could not be saved. Try again.' }); + return; + } + this.chunkRenderState = view.chunkState; + this.currentFilterView = view; + this.currentHeader = view.headerEnabled; + this.filterEpoch = rawRevision; + reply({}); + } + + private captureFilterDraft(raw: unknown): void { + this.pendingFilterDraft = undefined; + const columns = this.currentFilterView?.columns; + if (!columns || !raw || typeof raw !== 'object' || Array.isArray(raw)) { return; } + const item = raw as Record; + if (item.session !== this.filterSession || item.filterColumnsRevision !== this.filterColumnsRevision || + !Array.isArray(item.rows) || item.rows.length > MAX_COLUMN_FILTERS || + !Array.isArray(item.columns) || item.columns.length !== columns.length) { return; } + if (!item.columns.every((column: unknown, index: number) => { + if (!column || typeof column !== 'object' || Array.isArray(column)) { return false; } + const identity = column as Record; + return identity.column === columns[index].column && identity.label === columns[index].label; + })) { return; } + const rows: FilterDraft['rows'] = []; + for (const rawRow of item.rows as unknown[]) { + if (!rawRow || typeof rawRow !== 'object' || Array.isArray(rawRow)) { return; } + const row = rawRow as Record; + const operator = getFilterOperatorChoices('number').find(choice => choice.operator === row.operator)?.operator; + if (typeof row.column !== 'number' || !Number.isSafeInteger(row.column) || row.column < 0 || row.column >= columns.length || + !operator || typeof row.value !== 'string' || row.value.length > MAX_FILTER_VALUE_LENGTH || + (row.composing !== undefined && typeof row.composing !== 'boolean')) { return; } + // Preserve blank and invalid queries as drafts; only submission validates their meaning. + rows.push({ column: row.column, operator, value: row.value, ...(row.composing ? { composing: true } : {}) }); + } + const draft: FilterDraft = { + session: this.filterSession, + filterColumnsRevision: this.filterColumnsRevision, + rows, + columns: columns.map(column => ({ column: column.column, label: column.label })) + }; + if (item.focus !== undefined) { + if (!item.focus || typeof item.focus !== 'object' || Array.isArray(item.focus)) { return; } + const focus = item.focus as Record; + if (typeof focus.row !== 'number' || !Number.isSafeInteger(focus.row) || focus.row < 0 || focus.row >= rows.length || + (focus.field !== 'column' && focus.field !== 'operator' && focus.field !== 'value' && focus.field !== 'remove')) { return; } + draft.focus = { row: focus.row, field: focus.field }; + if (focus.selectionStart !== undefined || focus.selectionEnd !== undefined || focus.selectionDirection !== undefined) { + if (focus.field !== 'value' || typeof focus.selectionStart !== 'number' || typeof focus.selectionEnd !== 'number' || + !Number.isSafeInteger(focus.selectionStart) || !Number.isSafeInteger(focus.selectionEnd) || + focus.selectionStart < 0 || focus.selectionEnd < focus.selectionStart || focus.selectionEnd > rows[focus.row].value.length || + (focus.selectionDirection !== 'forward' && focus.selectionDirection !== 'backward' && focus.selectionDirection !== 'none')) { return; } + Object.assign(draft.focus, { selectionStart: focus.selectionStart, selectionEnd: focus.selectionEnd, selectionDirection: focus.selectionDirection }); + } + } + this.pendingFilterDraft = { uri: this.filterSessionUri, draft }; + } + + private consumeFilterDraft(columns: FilterColumn[]): FilterDraft | undefined { + const pending = this.pendingFilterDraft; + this.pendingFilterDraft = undefined; + if (!pending || pending.uri !== this.document.uri.toString() || pending.draft.filterColumnsRevision !== this.filterColumnsRevision || + pending.draft.columns.length !== columns.length || pending.draft.columns.some((column, index) => + column.column !== columns[index].column || column.label !== columns[index].label)) { return undefined; } + return pending.draft; + } + + private async remapFilters(columnOrder: number[]): Promise { + if (columnOrder.length !== this.currentFilterView?.columns.length || columnOrder.some((column, index) => column !== index)) { + this.filterColumnsRevision++; + this.pendingFilterDraft = undefined; + } + await CsvEditorProvider.setColumnFiltersForUri(this.context, this.document.uri, + remapColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), columnOrder)); + } + + public refreshViewSafely(): void { + this.refreshAfterDraftCommit = true; + this.requestViewCommit(); + } + + public getCurrentFrozenPanes(): FrozenPanes { + return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader); + } + + public async setFrozenPanes(change: Partial): Promise { + await CsvEditorProvider.setFrozenPanesForUri(this.context, this.document.uri, change); + this.currentWebviewPanel?.webview.postMessage({ type: 'frozenPanes', ...this.getCurrentFrozenPanes() }); + } + + public updateDocumentReference(document: vscode.TextDocument): void { + if (document.uri.toString() === this.document.uri.toString()) { + this.pendingDocumentReference = { + document, + separator: this.pendingDocumentReference?.separator ?? this.renderedSeparator ?? this.getSeparator() + }; + this.refreshAfterDraftCommit = true; + this.requestViewCommit(); + } + } + public getDocumentUri(): vscode.Uri { return this.document.uri; } @@ -396,13 +1046,50 @@ class CsvEditorController { // ───────────── Document Editing Methods ───────────── + private reportFailedCellEdit(row: number, col: number): void { + this.failedCellEdits.set(`${row}:${col}`, { row, col }); + void this.currentWebviewPanel?.webview.postMessage({ + type: 'editCellFailed', row, col, retry: !!this.viewCommitRequested + }); + } + + private postCommittedCell(row: number, col: number, value: string): void { + const clickableLinks = vscode.workspace.getConfiguration('csv', this.document.uri).get('clickableLinks', true); + void this.currentWebviewPanel?.webview.postMessage({ + type: 'updateCell', row, col, value, rendered: this.formatCellContent(value, clickableLinks) + }); + } + + private clearFailedCellEdit(row: number, col: number, value: string): void { + if (this.failedCellEdits.delete(`${row}:${col}`)) { + this.postCommittedCell(row, col, value); + if (!this.failedCellEdits.size) { this.requestRecoveryRefresh(); } + } + } + + private discardFailedCellDraft(row: number, col: number): void { + const failed = this.failedCellEdits.get(`${row}:${col}`); + if (!failed) { + return; + } + // Escape discards the failed DOM draft, never overwrites the current owner. + const document = this.pendingDocumentReference?.document ?? this.document; + const data = Papa.parse(document.getText(), { delimiter: this.getSeparator() }).data; + this.clearFailedCellEdit(row, col, String(data[row]?.[col] ?? '')); + + } + private async updateDocument(row: number, col: number, value: string) { + // The old document may be closed during a language change. Apply its final + // draft to the live shared URI using the delimiter of the still-visible grid. + const document = this.pendingDocumentReference?.document ?? this.document; this.isUpdatingDocument = true; let structuralChange = false; + let filterMembershipChanged = false; let applied = false; try { const separator = this.getSeparator(); - const oldText = this.document.getText(); + const oldText = document.getText(); const result = Papa.parse(oldText, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; const hadRows = data.length; @@ -412,10 +1099,15 @@ class CsvEditorController { ? String(data[row][col] ?? '') : undefined; + const filters = CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri); + const matches = compileColumnFilters(filters); + const previouslyMatched = matches(data[row] ?? []); const { data: nextData, trimmed, createdRow, createdCol } = this.mutateDataForEdit(data, row, col, value); + filterMembershipChanged = filters.length > 0 && previouslyMatched !== matches(nextData[row] ?? []); structuralChange = !!(trimmed || createdRow || createdCol || row >= hadRows || col >= hadColsAtRow); if (!structuralChange && previousValue === value) { + this.clearFailedCellEdit(row, col, value); return; } @@ -426,40 +1118,52 @@ class CsvEditorController { separator, [{ row, col, value: String(value ?? '') }] ); + } else { + newCsvText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(oldText, separator, nextData); } if (newCsvText === undefined) { newCsvText = Papa.unparse(nextData, { delimiter: separator }); } if (newCsvText === oldText) { + this.clearFailedCellEdit(row, col, value); return; } const fullRange = new vscode.Range( 0, 0, - this.document.lineCount, - this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0 + document.lineCount, + document.lineCount ? document.lineAt(document.lineCount - 1).text.length : 0 ); const edit = new vscode.WorkspaceEdit(); - edit.replace(this.document.uri, fullRange, newCsvText); - await vscode.workspace.applyEdit(edit); - applied = true; + edit.replace(document.uri, fullRange, newCsvText); + applied = await vscode.workspace.applyEdit(edit); + } catch (error) { + this.reportFailedCellEdit(row, col); + throw error; } finally { this.isUpdatingDocument = false; } if (!applied) { + this.reportFailedCellEdit(row, col); return; } + const recoveredDraft = this.failedCellEdits.delete(`${row}:${col}`); - console.log(`CSV: Updated row ${row + 1}, column ${col + 1} to "${value}"`); - const config = vscode.workspace.getConfiguration('csv', this.document.uri); + console.log(`CSV: Updated row ${row + 1}, column ${col + 1}`); + const config = vscode.workspace.getConfiguration('csv', document.uri); const clickableLinks = config.get('clickableLinks', true); const rendered = this.formatCellContent(value ?? '', clickableLinks); this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered }); // Trigger a full re-render if structure may have changed (new row/col created) - if (structuralChange) { + if (recoveredDraft && !this.failedCellEdits.size) { + this.requestRecoveryRefresh(); + } + if (filterMembershipChanged) { + this.refreshViewSafely(); + } else if (structuralChange && !this.pendingDocumentReference) { try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); } } } @@ -528,34 +1232,32 @@ class CsvEditorController { } } - private parseClipboardMatrix(text: string): string[][] { - if (!text || text.length === 0) { - return []; + private async copyCells(rawMatrix: unknown, rawRowIds: unknown, rawStartCol: unknown, cut: boolean): Promise { + if (!Array.isArray(rawMatrix) || rawMatrix.length === 0 || !rawMatrix.every(row => + Array.isArray(row) && row.length > 0 && row.every(value => typeof value === 'string') + )) { + return; } - const parsed = Papa.parse(text, { dynamicTyping: false, delimiter: '' }); - const rowsRaw = Array.isArray(parsed.data) ? parsed.data : []; - const matrix: string[][] = rowsRaw.map(rawRow => { - if (Array.isArray(rawRow)) { - return rawRow.map(cell => String(cell ?? '')); - } - return [String(rawRow ?? '')]; - }); - while (matrix.length > 0 && matrix[matrix.length - 1].every(value => value === '')) { - matrix.pop(); + const matrix = rawMatrix as string[][]; + const rowIds = Array.isArray(rawRowIds) ? rawRowIds : []; + const startCol = Number(rawStartCol); + if (rowIds.length !== matrix.length || new Set(rowIds).size !== rowIds.length || !rowIds.every(row => Number.isInteger(row) && row >= 0) || !Number.isInteger(startCol) || startCol < 0) { + return; } - if (matrix.length === 0) { - return []; + const documentVersion = this.document.version; + try { + await vscode.env.clipboard.writeText(serializeClipboardMatrix(matrix, this.getSeparator())); + } catch { + void vscode.window.showErrorMessage('CSV: Unable to write to the clipboard. The selected cells were kept.'); + return; } - const width = matrix.reduce((max, row) => Math.max(max, row.length), 0); - if (width <= 0) { - return []; + if (cut && this.document.version === documentVersion) { + await this.replaceCells(matrix.flatMap((row, rowOffset) => row.map((_value, colOffset) => ({ + row: rowIds[rowOffset], + col: startCol + colOffset, + value: '' + })))); } - matrix.forEach(row => { - while (row.length < width) { - row.push(''); - } - }); - return matrix; } private static parsePasteSelectionBounds(raw: unknown): PasteSelectionBounds | undefined { @@ -622,7 +1324,8 @@ class CsvEditorController { matrix: string[][], anchorRow: number, anchorCol: number, - selection: PasteSelectionBounds | undefined + selection: PasteSelectionBounds | undefined, + targetRows?: number[] ): PasteApplyResult { const plan = CsvEditorController.computePastePlan(matrix, anchorRow, anchorCol, selection); if (!plan) { @@ -640,6 +1343,11 @@ class CsvEditorController { }; } + const rowsToWrite = targetRows ?? Array.from({ length: plan.endRow - plan.startRow + 1 }, (_, index) => plan.startRow + index); + if (rowsToWrite.length) { + plan.startRow = rowsToWrite[0]; + plan.endRow = rowsToWrite[rowsToWrite.length - 1]; + } const updates: Array<{ row: number; col: number; value: string }> = []; let changed = false; let structuralChange = false; @@ -667,7 +1375,7 @@ class CsvEditorController { if (plan.fillSelection) { const value = String(matrix[0][0] ?? ''); - for (let row = plan.startRow; row <= plan.endRow; row++) { + for (const row of rowsToWrite) { for (let col = plan.startCol; col <= plan.endCol; col++) { setCellValue(row, col, value); } @@ -675,7 +1383,7 @@ class CsvEditorController { } else { for (let r = 0; r < matrix.length; r++) { for (let c = 0; c < matrix[r].length; c++) { - setCellValue(plan.startRow + r, plan.startCol + c, String(matrix[r][c] ?? '')); + setCellValue(rowsToWrite[r], plan.startCol + c, String(matrix[r][c] ?? '')); } } } @@ -698,7 +1406,7 @@ class CsvEditorController { if (!Number.isInteger(anchorRow) || anchorRow < 0 || !Number.isInteger(anchorCol) || anchorCol < 0) { return; } - const matrix = this.parseClipboardMatrix(text); + const matrix = parseClipboardMatrix(text, this.getSeparator()); if (!matrix.length || !matrix[0].length) { return; } @@ -709,9 +1417,21 @@ class CsvEditorController { const separator = this.getSeparator(); const oldText = this.document.getText(); const result = Papa.parse(oldText, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; - - const pasteResult = CsvEditorController.applyPasteMatrixToData(data, matrix, anchorRow, anchorCol, selection); + const data = this.trimTrailingEmptyRows(result.data as string[][]); + + const realData = this.trimTrailingEmptyRows(data); + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), data.reduce((max, row) => Math.max(max, row.length), 0)); + const visibleRows = [...visibleSourceRows(realData, this.getHiddenRows(), this.getEffectiveHeader(realData, this.getHiddenRows()), filters), realData.length]; + const anchorIndex = visibleRows.indexOf(anchorRow); + if (anchorIndex < 0) { + void vscode.window.showInformationMessage('CSV: Select a visible cell and paste again.'); + return; + } + const plan = CsvEditorController.computePastePlan(matrix, anchorRow, anchorCol, selection); + const targetRows = plan?.fillSelection + ? visibleRows.filter(row => row >= plan.startRow && row <= plan.endRow) + : matrix.map((_row, index) => visibleRows[anchorIndex + index] ?? realData.length + anchorIndex + index - visibleRows.length + 1); + const pasteResult = CsvEditorController.applyPasteMatrixToData(data, matrix, anchorRow, anchorCol, selection, targetRows); if (!pasteResult.changed) { return; } @@ -719,6 +1439,8 @@ class CsvEditorController { let newCsvText: string | undefined; if (!pasteResult.structuralChange) { newCsvText = CsvEditorProvider.applyFieldUpdatesPreservingFormat(oldText, separator, pasteResult.updates); + } else { + newCsvText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(oldText, separator, this.trimTrailingEmptyRows(data)); } if (newCsvText === undefined) { newCsvText = Papa.unparse(data, { delimiter: separator }); @@ -757,17 +1479,17 @@ class CsvEditorController { if (start < state.allRowsCount) { const end = Math.min(start + state.chunkRows, state.allRowsCount); const html = state.allRows.slice(start, end).map((row, localR) => { - const absRow = state.startAbs + start + localR; - const displayIdx = start + localR + 1; + const absRow = state.rowIds[start + localR]; + const displayIdx = absRow - state.bodyStart + 1; let cells = ''; for (let cIdx = 0; cIdx < state.numColumns; cIdx++) { const rawValue = row[cIdx] || ''; const safe = this.formatCellContent(rawValue, state.clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - cells += `${safe}`; + cells += `${safe}`; } const idxCell = state.addSerialIndex - ? `${displayIdx}` + ? `${displayIdx}` : ''; return `${idxCell}${cells}`; }).join(''); @@ -782,26 +1504,28 @@ class CsvEditorController { } if (start === state.allRowsCount && state.includeTrailingEmptyRow) { - const virtualAbs = state.startAbs + state.allRowsCount; - const displayIdx = state.allRowsCount + 1; + const virtualAbs = state.virtualRow; + const displayIdx = virtualAbs - state.bodyStart + 1; const idxCell = state.addSerialIndex - ? `${displayIdx}` + ? `${displayIdx}` : ''; - const dataCells = Array.from({ length: state.numColumns }, (_, i) => ``).join(''); + const dataCells = Array.from({ length: state.numColumns }, (_, i) => ``).join(''); return { html: `${idxCell}${dataCells}`, nextStart: -1, done: true }; } return { html: '', nextStart: -1, done: true }; } - private async requestChunk(rawStart: unknown, requestId: unknown): Promise { - if (!this.currentWebviewPanel || !this.chunkRenderState) { + private async requestChunk(rawStart: unknown, requestId: unknown, filterEpoch: unknown, session: unknown): Promise { + if (!this.currentWebviewPanel || !this.chunkRenderState || filterEpoch !== this.filterEpoch || session !== this.filterSession) { return; } const start = Number(rawStart); if (!Number.isInteger(start) || start < 0) { this.currentWebviewPanel.webview.postMessage({ type: 'chunkData', + session: this.filterSession, + filterEpoch: this.filterEpoch, requestId, start: -1, html: '', @@ -813,6 +1537,8 @@ class CsvEditorController { const response = this.renderChunkFromState(this.chunkRenderState, start); this.currentWebviewPanel.webview.postMessage({ type: 'chunkData', + session: this.filterSession, + filterEpoch: this.filterEpoch, requestId, start, html: response.html, @@ -882,7 +1608,8 @@ class CsvEditorController { const offset = Math.min(Math.max(0, hiddenRows), data.length); const matches: Array<{ row: number; col: number; value: string }> = []; - for (let row = offset; row < data.length; row++) { + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), data.reduce((max, row) => Math.max(max, row.length), 0)); + for (const row of visibleSourceRows(data, offset, this.getEffectiveHeader(data, hiddenRows), filters)) { const current = data[row] || []; for (let col = 0; col < current.length; col++) { const value = String(current[col] ?? ''); @@ -949,6 +1676,13 @@ class CsvEditorController { private async handleSave() { this.isSaving = true; try { + if (this.document.uri.scheme === 'untitled') { + // Native Save owns the actual Save As transition. The temporary grid + // closes when VS Code closes the original untitled document. + await vscode.window.showTextDocument(this.document, { viewColumn: this.currentWebviewPanel?.viewColumn, preview: false }); + await vscode.commands.executeCommand('workbench.action.files.save'); + return; + } const success = await this.document.save(); console.log(success ? 'CSV: Document saved' : 'CSV: Failed to save document'); } catch (error) { @@ -963,14 +1697,17 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); for (const row of data) { if (index > row.length) { while (row.length < index) row.push(''); } row.splice(index, 0, ''); } - const newText = Papa.unparse(data, { delimiter: separator }); + order.splice(index, 0, -1); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, undefined, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -980,6 +1717,7 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + await this.remapFilters(order); this.updateWebviewContent(); } @@ -989,7 +1727,8 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); for (let k = 0; k < count; k++) { for (const row of data) { if (index > row.length) { @@ -998,7 +1737,9 @@ class CsvEditorController { row.splice(index, 0, ''); } } - const newText = Papa.unparse(data, { delimiter: separator }); + order.splice(index, 0, ...Array(count).fill(-1)); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, undefined, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1008,6 +1749,7 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1016,13 +1758,16 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); for (const row of data) { if (index < row.length) { row.splice(index, 1); } } - const newText = Papa.unparse(data, { delimiter: separator }); + order.splice(index, 1); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, undefined, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1032,6 +1777,7 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1041,7 +1787,8 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); const sorted = [...indices].sort((a,b)=>b-a); for (const idx of sorted) { for (const row of data) { @@ -1050,7 +1797,9 @@ class CsvEditorController { } } } - const newText = Papa.unparse(data, { delimiter: separator }); + for (const column of sorted) { order.splice(column, 1); } + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, undefined, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1060,6 +1809,7 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1110,26 +1860,22 @@ class CsvEditorController { return sa.localeCompare(sb, undefined, { sensitivity: 'base' }); }; - body.sort((r1, r2) => { + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), rows.reduce((max, row) => Math.max(max, row.length), 0)); + const matches = compileColumnFilters(filters); + const matchingSlots = body.map((_row, slot) => slot).filter(slot => matches(body[slot])); + const sortedMatches = matchingSlots.map(slot => body[slot]).sort((r1, r2) => { const diff = cmp(r1[index] ?? '', r2[index] ?? ''); return ascending ? diff : -diff; }); + matchingSlots.forEach((slot, order) => { body[slot] = sortedMatches[order]; }); const prefix = rows.slice(0, offset); const combined = treatHeader ? [...prefix, header, ...body] : [...prefix, ...body]; - // Sanitize before unparse: ensure undefined/null/NaN become empty strings - const sanitized: string[][] = combined.map(r => r.map((v: any) => { - if (v === undefined || v === null) return ''; - const t = typeof v; - if (t === 'number') { - return Number.isNaN(v) ? '' : String(v); - } - const s = String(v); - return s.toLowerCase() === 'nan' ? '' : s; - })); - - const newCsv = Papa.unparse(sanitized, { delimiter: separator }); + const originalIndex = new Map(rows.map((row, position) => [row, position])); + const rowOrder = combined.map(row => originalIndex.get(row)!); + const newCsv = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, combined, rowOrder) + ?? Papa.unparse(combined, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, @@ -1151,14 +1897,17 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = data.map((_row, position) => position); const numColumns = data.reduce((max, r) => Math.max(max, r.length), 0); const newRow = Array(numColumns).fill(''); if (index > data.length) { while (data.length < index) data.push(Array(numColumns).fill('')); } data.splice(index, 0, newRow); - const newText = Papa.unparse(data, { delimiter: separator }); + order.splice(index, 0, -1); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1177,7 +1926,8 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = data.map((_row, position) => position); const numColumns = data.reduce((max, r) => Math.max(max, r.length), 0); for (let k = 0; k < count; k++) { const newRow = Array(numColumns).fill(''); @@ -1186,7 +1936,9 @@ class CsvEditorController { } data.splice(index, 0, newRow); } - const newText = Papa.unparse(data, { delimiter: separator }); + order.splice(index, 0, ...Array(count).fill(-1)); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1204,11 +1956,14 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = data.map((_row, position) => position); if (index < data.length) { data.splice(index, 1); } - const newText = Papa.unparse(data, { delimiter: separator }); + order.splice(index, 1); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1227,14 +1982,17 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); + const order = data.map((_row, position) => position); const sorted = [...indices].sort((a,b)=>b-a); for (const idx of sorted) { if (idx < data.length) { data.splice(idx, 1); } } - const newText = Papa.unparse(data, { delimiter: separator }); + for (const row of sorted) { order.splice(row, 1); } + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, data, order) + ?? Papa.unparse(data, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1296,7 +2054,7 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); const numColumns = data.reduce((max, row) => Math.max(max, row.length), 0); if (numColumns <= 0) return; @@ -1307,13 +2065,13 @@ class CsvEditorController { const reorderedData = data.map(row => { const normalized = Array.from({ length: numColumns }, (_, i) => row[i] ?? ''); const next = columnOrder.map(colIdx => normalized[colIdx] ?? ''); - while (next.length > 0 && next[next.length - 1] === '') { - next.pop(); - } - return next; + // Keep explicit empty fields; omit only positions absent from the source row. + const lastPresent = columnOrder.reduce((last, colIdx, index) => colIdx < row.length ? index : last, -1); + return next.slice(0, lastPresent + 1); }); - const newText = Papa.unparse(reorderedData, { delimiter: separator }); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, reorderedData, undefined, columnOrder) + ?? Papa.unparse(reorderedData, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1322,6 +2080,7 @@ class CsvEditorController { const edit = new vscode.WorkspaceEdit(); edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); + await this.remapFilters(columnOrder); this.updateWebviewContent(); } finally { this.isUpdatingDocument = false; @@ -1334,13 +2093,15 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); if (!data.length) return; - const { reordered, changed } = this.reorderByIndices(data, indices, beforeIndex); + const { reordered: rowOrder, changed } = this.reorderByIndices(data.map((_, index) => index), indices, beforeIndex); if (!changed) return; - const newText = Papa.unparse(reordered, { delimiter: separator }); + const reordered = rowOrder.map(index => data[index]); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, reordered, rowOrder) + ?? Papa.unparse(reordered, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1358,13 +2119,16 @@ class CsvEditorController { // ───────────── Webview Rendering ───────────── private updateWebviewContent() { - if (!this.currentWebviewPanel) return; + if (!this.currentWebviewPanel || this.failedCellEdits.size || this.pendingDocumentReference) return; + if (this.viewCommitRequested) { + this.refreshAfterDraftCommit = true; + return; + } const webview = this.currentWebviewPanel.webview; const config = vscode.workspace.getConfiguration('csv', this.document.uri); - const addSerialIndex = CsvEditorProvider.getSerialIndexForUri(this.context, this.document.uri); const separator = this.getSeparator(); - const hiddenRows = this.getHiddenRows(); + this.renderedSeparator = separator; let parsed; try { @@ -1385,33 +2149,19 @@ class CsvEditorController { const cellPadding = config.get('cellPadding', 4); const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]); - const treatHeader = this.getEffectiveHeader(data, hiddenRows); - const clickableLinks = config.get('clickableLinks', true); - const configuredColumnColorMode = config.get('columnColorMode', 'type'); - const diffUseThemeForeground = config.get('diffUseThemeForeground', true); - const columnColorMode = CsvEditorController.resolveEffectiveColumnColorMode( - configuredColumnColorMode, - this.isDiffContext, - diffUseThemeForeground - ); - const columnColorPalette = config.get('columnColorPalette', 'default'); - const showTrailingEmptyRow = config.get('showTrailingEmptyRow', true); + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), data.reduce((max, row) => Math.max(max, row.length), 0)); + const view = this.buildFilterView(data, filters); + const filterDraft = this.consumeFilterDraft(view.columns); + this.currentHeader = view.headerEnabled; + this.chunkRenderState = view.chunkState; + this.currentFilterView = view; + this.latestFilterRevision = 0; + this.filterEpoch = 0; + this.filterSession = this.getNonce(); + this.filterSessionUri = this.document.uri.toString(); const mouseWheelZoomEnabled = config.get('mouseWheelZoom', true); const mouseWheelZoomInvert = config.get('mouseWheelZoomInvert', false); - - const { tableHtml, chunksJson, colorCss, nextChunkStart, hasRemoteChunks, chunkState } = - this.generateTableAndChunks( - data, - treatHeader, - addSerialIndex, - hiddenRows, - clickableLinks, - columnColorMode, - columnColorPalette, - showTrailingEmptyRow, - /* maxSerializedChunks */ 0 - ); - this.chunkRenderState = chunkState; + const { tableHtml, chunksJson, colorCss, nextChunkStart, hasRemoteChunks } = view; const nonce = this.getNonce(); @@ -1427,11 +2177,36 @@ class CsvEditorController { extraColumnColorCss: colorCss, nextChunkStart, hasRemoteChunks, + headerEnabled: view.headerEnabled, + firstVisibleRow: view.firstVisibleRow, mouseWheelZoomEnabled, - mouseWheelZoomInvert + mouseWheelZoomInvert, + columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri), + frozenPanes: this.getCurrentFrozenPanes(), + realRowCount: data.length, + visibleRowIds: view.visibleRowIds, + filterMetadata: { session: this.filterSession, filterColumnsRevision: this.filterColumnsRevision, filters, columns: view.columns, matchedRows: view.matchedRows, totalRows: view.totalRows, maxFilters: MAX_COLUMN_FILTERS, revision: 0, ...(filterDraft ? { filterDraft } : {}) } }); } + private buildFilterView(data: string[][], filters: ColumnFilter[]): FilterRender { + const config = vscode.workspace.getConfiguration('csv', this.document.uri); + const hiddenRows = Math.min(Math.max(0, this.getHiddenRows()), data.length); + const headerEnabled = this.getEffectiveHeader(data, hiddenRows); + const columnColorMode = CsvEditorController.resolveEffectiveColumnColorMode( + config.get('columnColorMode', 'type'), this.isDiffContext, config.get('diffUseThemeForeground', true)); + const render = this.generateTableAndChunks(data, headerEnabled, + CsvEditorProvider.getSerialIndexForUri(this.context, this.document.uri), hiddenRows, + config.get('clickableLinks', true), columnColorMode, config.get('columnColorPalette', 'default'), + config.get('showTrailingEmptyRow', true), 0, filters); + const visible = visibleSourceRows(data, hiddenRows, headerEnabled, filters); + return { ...render, filters, columns: this.getFilterColumns(data), headerEnabled, + firstVisibleRow: hiddenRows, realRowCount: data.length, + visibleRowIds: filters.length ? [...visible, data.length] : [], + matchedRows: visible.filter(row => !(headerEnabled && row === hiddenRows)).length, + totalRows: Math.max(0, data.length - hiddenRows - (headerEnabled ? 1 : 0)) }; + } + private generateTableAndChunks( data: string[][], treatHeader: boolean, @@ -1441,7 +2216,8 @@ class CsvEditorController { columnColorMode: string, columnColorPalette: string, showTrailingEmptyRow: boolean, - maxSerializedChunks: number = Number.MAX_SAFE_INTEGER + maxSerializedChunks: number = Number.MAX_SAFE_INTEGER, + filters: ColumnFilter[] = [] ): { tableHtml: string; chunksJson: string; @@ -1454,7 +2230,8 @@ class CsvEditorController { const totalRows = data.length; const offset = Math.min(Math.max(0, hiddenRows), totalRows); - const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const themeKind = vscode.window.activeColorTheme.kind; + const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; let headerRow: string[] = []; let bodyData: string[][] = []; if (totalRows === 0 || offset >= totalRows) { @@ -1466,8 +2243,12 @@ class CsvEditorController { } else { bodyData = data.slice(offset); } + const bodyStart = headerFlag ? offset + 1 : offset; + const matches = compileColumnFilters(filters); + const rowIds = bodyData.map((_row, index) => bodyStart + index).filter(row => matches(data[row])); + bodyData = rowIds.map(row => data[row]); const visibleForWidth = headerFlag ? [headerRow, ...bodyData] : bodyData; - let numColumns = visibleForWidth.reduce((max, row) => Math.max(max, row.length), 0); + let numColumns = data.reduce((max, row) => Math.max(max, row.length), 0); if (numColumns === 0) numColumns = 1; // ensure at least 1 column for the virtual row const columnData = Array.from({ length: numColumns }, (_, i) => bodyData.map(row => row[i] || '')); @@ -1478,13 +2259,13 @@ class CsvEditorController { : (columnColorPalette === 'warm' ? 'warm' : 'default'); const columnColors = useThemeForeground ? Array.from({ length: numColumns }, () => 'var(--vscode-editor-foreground)') - : columnTypes.map((type, i) => this.getColumnColor(type, isDark, i, palette)); + : columnTypes.map((type, i) => `var(--csv-column-${i}, ${this.getColumnColor(type, isDark, i, palette)})`); const columnWidths = this.computeColumnWidths(visibleForWidth); const BASE_CHUNK_ROWS = 1000; const MAX_CELLS_PER_CHUNK = 20000; const MIN_CHUNK_ROWS = 10; - const allRows = headerFlag ? data.slice(offset + 1) : data.slice(offset); + const allRows = bodyData; const allRowsCount = allRows.length; // preserve total before any truncation const chunkRows = Math.max( MIN_CHUNK_ROWS, @@ -1493,7 +2274,7 @@ class CsvEditorController { // Always keep one editable row for fully empty views; otherwise allow disabling the // trailing virtual row via settings. const includeTrailingEmptyRow = showTrailingEmptyRow || allRowsCount === 0; - const serialIndexMaxDisplay = includeTrailingEmptyRow ? allRowsCount + 1 : allRowsCount; + const serialIndexMaxDisplay = data.length - bodyStart + (includeTrailingEmptyRow ? 1 : 0); const serialIndexWidthCh = Math.max(4, String(Math.max(1, serialIndexMaxDisplay)).length + 1); const chunks: string[] = []; const chunked = allRowsCount > chunkRows; @@ -1510,19 +2291,18 @@ class CsvEditorController { break; } const htmlChunk = allRows.slice(i, i + chunkRows).map((row, localR) => { - const startAbs = headerFlag ? offset + 1 : offset; - const absRow = startAbs + i + localR; - const displayIdx = i + localR + 1; // numbering relative to first visible data row + const absRow = rowIds[i + localR]; + const displayIdx = absRow - bodyStart + 1; // retain source numbering across filtered gaps let cells = ''; for (let cIdx = 0; cIdx < numColumns; cIdx++) { const rawValue = row[cIdx] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - cells += `${safe}`; + cells += `${safe}`; } return `${ - addSerialIndex ? `${displayIdx}` : '' + addSerialIndex ? `${displayIdx}` : '' }${cells}`; }).join(''); @@ -1531,41 +2311,44 @@ class CsvEditorController { } } + // Body classes change in place; inherited variables update every mounted or + // later-loaded cell without overriding the user's mode or reloading a draft. const colorCss = useThemeForeground ? '' - : columnColors.map((hex, i) => `td[data-col="${i}"], th[data-col="${i}"] { color: ${hex}; }`).join(''); + : `body:is(.vscode-light, .vscode-high-contrast-light) { ${columnTypes.map((type, i) => `--csv-column-${i}: ${this.getColumnColor(type, false, i, palette)};`).join('')} }` + + `body:is(.vscode-dark, .vscode-high-contrast) { ${columnTypes.map((type, i) => `--csv-column-${i}: ${this.getColumnColor(type, true, i, palette)};`).join('')} }`; let tableHtml = ``; if (headerFlag) { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { const safe = this.formatCellContent(headerRow[i] || '', clickableLinks); - tableHtml += ``; + tableHtml += ``; } tableHtml += ``; const initialBodyRows = chunked ? allRows.slice(0, chunkRows) : allRows; initialBodyRows.forEach((row, r) => { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += ``; + tableHtml += ``; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { - const virtualAbs = offset + 1 + initialBodyRows.length; - const idxCell = addSerialIndex ? `` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const virtualAbs = data.length; + const idxCell = addSerialIndex ? `` : ''; + const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); tableHtml += `${idxCell}${dataCells}`; } tableHtml += ``; @@ -1575,22 +2358,22 @@ class CsvEditorController { nonHeaderRows.forEach((row, r) => { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += ``; + tableHtml += ``; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { - const virtualAbs = offset + nonHeaderRows.length; - const displayIdx = nonHeaderRows.length + 1; - const idxCell = addSerialIndex ? `` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const virtualAbs = data.length; + const displayIdx = virtualAbs - bodyStart + 1; + const idxCell = addSerialIndex ? `` : ''; + const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); tableHtml += `${idxCell}${dataCells}`; } tableHtml += ``; @@ -1599,11 +2382,10 @@ class CsvEditorController { // If chunked, append a final chunk with the virtual row so it appears at the end. if (chunked && includeTrailingEmptyRow) { if (nextChunkStart === -1 && serializedChunkCount < safeMaxSerializedChunks) { - const startAbs = headerFlag ? offset + 1 : offset; - const virtualAbs = startAbs + allRowsCount; - const displayIdx = allRowsCount + 1; - const idxCell = addSerialIndex ? `` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const virtualAbs = data.length; + const displayIdx = virtualAbs - bodyStart + 1; + const idxCell = addSerialIndex ? `` : ''; + const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); const vrow = `${idxCell}${dataCells}`; chunks.push(vrow); } else if (nextChunkStart === -1) { @@ -1614,7 +2396,9 @@ class CsvEditorController { const hasRemoteChunks = nextChunkStart >= 0; const chunkState: ChunkRenderState | undefined = chunked ? { - startAbs: headerFlag ? offset + 1 : offset, + rowIds, + bodyStart, + virtualRow: data.length, allRows, allRowsCount, chunkRows, @@ -1667,6 +2451,12 @@ class CsvEditorController { return !matches; } + private getMediaUri(...segments: string[]): vscode.Uri { + return this.context.extensionUri.with({ + path: path.posix.join(this.context.extensionUri.path, 'media', ...segments) + }); + } + private wrapHtml(args: { webview: vscode.Webview; nonce: string; @@ -1679,14 +2469,30 @@ class CsvEditorController { extraColumnColorCss: string; nextChunkStart: number; hasRemoteChunks: boolean; + headerEnabled: boolean; mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; + columnSizes: Record; + frozenPanes: FrozenPanes; + firstVisibleRow: number; + realRowCount: number; + visibleRowIds: number[]; + filterMetadata: { session: string; filterColumnsRevision: number; filters: ColumnFilter[]; columns: FilterColumn[]; matchedRows: number; totalRows: number; maxFilters: number; revision: number; filterDraft?: FilterDraft }; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = args; - const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; - // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, headerEnabled, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes, frozenPanes, firstVisibleRow, realRowCount, visibleRowIds, filterMetadata } = args; + const themeKind = vscode.window.activeColorTheme.kind; + const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; const scriptUri = webview.asWebviewUri( - vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) + this.getMediaUri('main.js') + ); + + const filterScriptUri = webview.asWebviewUri(this.getMediaUri('column-filters.js')); + const filterMetadataJson = JSON.stringify(filterMetadata).replace(/[<>&\u2028\u2029]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0')); + const frozenPanesScriptUri = webview.asWebviewUri( + this.getMediaUri('frozen-panes.js') + ); + const contrastStyleUri = webview.asWebviewUri( + this.getMediaUri('high-contrast.css') ); // Safe separator transport (assumes single character; see assumptions) @@ -1701,18 +2507,41 @@ class CsvEditorController { CSV + + -
+
+
${tableHtml}
+
@@ -1930,7 +2795,7 @@ class CsvEditorController {
- +
+ + `; @@ -1954,7 +2821,9 @@ class CsvEditorController { const widths = Array(numColumns).fill(0); for (const row of data) { for (let i = 0; i < numColumns; i++){ - widths[i] = Math.max(widths[i], (row[i] || '').length); + for (const line of (row[i] || '').split(/\r\n|[\r\n]/)) { + widths[i] = Math.max(widths[i], line.length); + } } } console.log(`CSV: Column widths: ${widths}`); @@ -1962,6 +2831,12 @@ class CsvEditorController { } private getSeparator(): string { + if (this.pendingDocumentReference) { + return this.pendingDocumentReference.separator; + } + if ((this.failedCellEdits.size || this.refreshAfterDraftCommit) && this.renderedSeparator !== undefined) { + return this.renderedSeparator; + } const stored = CsvEditorProvider.getSeparatorForUri(this.context, this.document.uri); if (stored && stored.length) return stored; @@ -1976,7 +2851,9 @@ class CsvEditorController { return this.separatorCache.separator; } - const filePath = this.document?.uri.fsPath || this.document?.uri.path || ''; + const filePath = this.document.uri.scheme === 'untitled' + ? this.document.languageId === 'tsv' ? 'untitled.tsv' : 'untitled.csv' + : this.document?.uri.fsPath || this.document?.uri.path || ''; const text = this.document.getText(); const separator = CsvEditorProvider.resolveInheritedSeparator(filePath, text, settings); this.separatorCache = { version, configKey, separator }; @@ -2202,6 +3079,9 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static readonly headerKey = 'csv.headerByUri'; public static readonly serialKey = 'csv.serialIndexByUri'; public static readonly sepKey = 'csv.separatorByUri'; + public static readonly columnSizesKey = 'csv.columnSizesByUri'; + public static readonly frozenPanesKey = 'csv.frozenPanesByUri'; + public static readonly columnFiltersKey = 'csv.columnFiltersByUri'; private static readonly DEFAULT_SEPARATOR = ','; private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension'; private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record = { @@ -2386,8 +3266,8 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { const sep = delimiter && delimiter.length ? delimiter : CsvEditorProvider.DEFAULT_SEPARATOR; const rows: CsvFieldSpan[][] = []; let row: CsvFieldSpan[] = []; - let fieldStart = 0; - let i = 0; + let fieldStart = text.startsWith('\uFEFF') ? 1 : 0; + let i = fieldStart; let inQuotes = false; let quoted = false; @@ -2507,6 +3387,90 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { return output; } + public static applyStructuralUpdatesPreservingFormat( + text: string, + delimiter: string, + data: string[][], + rowOrder?: number[], + columnOrder?: number[] + ): string | undefined { + const parsed = Papa.parse(text, { delimiter, dynamicTyping: false }); + if (parsed.errors.some(error => error.type === 'Quotes')) { + return undefined; + } + const original = parsed.data; + let contentRowCount = original.length; + while (contentRowCount > 0 && original[contentRowCount - 1].every(value => value === '')) { + contentRowCount--; + } + const spans = CsvEditorProvider.parseCsvFieldSpans(text, delimiter); + // Pasting into the displayed append row can fill an existing empty source + // record. Consume that record rather than retaining it again as a suffix. + for (let row = 0; row < data.length; row++) { + const sourceRow = rowOrder?.[row] ?? row; + const sourceEnd = spans[sourceRow]?.slice(-1)[0]?.end; + const sourceStart = spans[sourceRow]?.[0]?.start; + if (sourceEnd !== undefined && (sourceEnd < text.length || (sourceStart ?? sourceEnd) < sourceEnd) && data[row].some(value => value !== '')) { + contentRowCount = Math.max(contentRowCount, sourceRow + 1); + } + } + const bom = text.startsWith('\uFEFF') ? '\uFEFF' : ''; + const lastSpan = contentRowCount > 0 ? spans[contentRowCount - 1]?.slice(-1)[0] : undefined; + const suffix = text.slice(lastSpan?.end ?? bom.length); + const rowBreak = (row: number): string | undefined => { + const end = spans[row]?.slice(-1)[0]?.end; + if (end === undefined) { + return undefined; + } + return /^(\r\n|\n|\r)/.exec(text.slice(end))?.[0]; + }; + const defaultBreak = parsed.meta.linebreak || '\n'; + const columnPrefersQuotes = (col: number): boolean => { + // The body supplies the convention when a header uses a different style. + const sample = spans.slice(contentRowCount > 1 ? 1 : 0, contentRowCount) + .map(row => row[col]).filter((span): span is CsvFieldSpan => !!span); + return sample.length > 0 && sample.every(span => span.quoted); + }; + const quotedColumns = new Map(); + let output = bom; + for (let row = 0; row < data.length; row++) { + const sourceRow = rowOrder?.[row] ?? row; + const fields: string[] = []; + for (let col = 0; col < data[row].length; col++) { + const sourceCol = columnOrder?.[col] ?? col; + const span = sourceRow < contentRowCount ? spans[sourceRow]?.[sourceCol] : undefined; + const value = data[row][col] ?? ''; + if (span && original[sourceRow]?.[sourceCol] === value) { + fields.push(text.slice(span.start, span.end)); + } else { + if (!quotedColumns.has(sourceCol)) { + quotedColumns.set(sourceCol, columnPrefersQuotes(sourceCol)); + } + fields.push(CsvEditorProvider.encodeCsvField(value, delimiter, span?.quoted ?? quotedColumns.get(sourceCol) ?? false)); + } + } + output += fields.join(delimiter); + if (row < data.length - 1) { + output += rowBreak(sourceRow) ?? defaultBreak; + } + } + output += suffix; + // Papa Parse remains authoritative: never reuse source tokens if their + // reconstruction would change the requested values or field boundaries. + const actual = Papa.parse(output, { delimiter, dynamicTyping: false }); + const trimEmpty = (rows: string[][]) => { + const result = [...rows]; + while (result.length > 0 && result[result.length - 1].every(value => value === '')) { + result.pop(); + } + return result; + }; + if (actual.errors.some(error => error.type === 'Quotes') || JSON.stringify(trimEmpty(actual.data)) !== JSON.stringify(trimEmpty(data))) { + return undefined; + } + return output; + } + constructor(private readonly context: vscode.ExtensionContext) {} public async resolveCustomTextEditor( @@ -2516,31 +3480,36 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { ): Promise { console.log(`CSV(reg): creating controller for ${document.uri.toString()}`); const controller = new CsvEditorController(this.context); - // Track active controller - webviewPanel.onDidChangeViewState(e => { - if (e.webviewPanel.active) { - CsvEditorProvider.currentActive = controller; - } - }); await controller.resolveCustomTextEditor(document, webviewPanel, _token); } public static getActiveProvider(): CsvEditorController | undefined { - return CsvEditorProvider.currentActive || CsvEditorProvider.editors.find(ed => ed.isActive()); + const current = CsvEditorProvider.currentActive; + return current?.isActive() ? current : CsvEditorProvider.editors.find(ed => ed.isActive()); + } + + public static hasHiddenRowsOverride(context: vscode.ExtensionContext, uri: vscode.Uri): boolean { + const map = context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {}); + const saved = map[uri.toString()]; + return Object.prototype.hasOwnProperty.call(map, uri.toString()) && typeof saved === 'number' && Number.isFinite(saved); } public static getHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri): number { const map = context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {}); - const n = map[uri.toString()] ?? 0; - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + const saved = map[uri.toString()]; + if (Object.prototype.hasOwnProperty.call(map, uri.toString()) && typeof saved === 'number' && Number.isFinite(saved)) { + return Math.max(0, Math.floor(saved)); + } + const configured = vscode.workspace.getConfiguration('csv', uri).get('defaultIgnoreRows', 0); + return typeof configured === 'number' && Number.isFinite(configured) ? Math.max(0, Math.floor(configured)) : 0; } - public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number): Promise { - const map = { ...(context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {})) }; - if (!Number.isFinite(n) || n <= 0) { + public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number | undefined): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {}) }; + if (n === undefined || !Number.isFinite(n)) { delete map[uri.toString()]; } else { - map[uri.toString()] = Math.floor(n); + map[uri.toString()] = Math.max(0, Math.floor(n)); } await context.workspaceState.update(CsvEditorProvider.hiddenRowsKey, map); } @@ -2572,6 +3541,74 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { await context.workspaceState.update(CsvEditorProvider.serialKey, map); } + public static getColumnFiltersForUri(context: vscode.ExtensionContext, uri: vscode.Uri): ColumnFilter[] { + const map = context.workspaceState.get>(CsvEditorProvider.columnFiltersKey, {}); + return normalizeColumnFilters(map[uri.toString()]); + } + + public static async setColumnFiltersForUri(context: vscode.ExtensionContext, uri: vscode.Uri, filters: unknown): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.columnFiltersKey, {}) }; + const normalized = normalizeColumnFilters(filters); + if (normalized.length) { + map[uri.toString()] = normalized; + } else { + delete map[uri.toString()]; + } + await context.workspaceState.update(CsvEditorProvider.columnFiltersKey, map); + } + + public static getFrozenPanesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, header: boolean): FrozenPanes { + const map = context.workspaceState.get>>(CsvEditorProvider.frozenPanesKey, {}); + const saved = map[uri.toString()] ?? {}; + const valid = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + return { rows: valid(saved.rows) ? saved.rows : (header ? 1 : 0), columns: valid(saved.columns) ? saved.columns : 0 }; + } + + public static async setFrozenPanesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, change: Partial): Promise { + const map = { ...context.workspaceState.get>>(CsvEditorProvider.frozenPanesKey, {}) }; + const saved = { ...map[uri.toString()] }; + for (const axis of ['rows', 'columns'] as const) { + const value = change[axis]; + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { + saved[axis] = value; + } + } + map[uri.toString()] = saved; + await context.workspaceState.update(CsvEditorProvider.frozenPanesKey, map); + } + + public static normalizeColumnSizes(raw: unknown): Record { + const sizes: Record = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return sizes; + } + for (const [key, value] of Object.entries(raw)) { + const col = Number(key); + const width = Number(value); + if (!Number.isInteger(col) || col < 0 || !Number.isFinite(width) || width < 40) { + continue; + } + sizes[String(col)] = Math.round(width); + } + return sizes; + } + + public static getColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri): Record { + const map = context.workspaceState.get>(CsvEditorProvider.columnSizesKey, {}); + return CsvEditorProvider.normalizeColumnSizes(map[uri.toString()]); + } + + public static async setColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, raw: unknown): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.columnSizesKey, {}) }; + const sizes = CsvEditorProvider.normalizeColumnSizes(raw); + if (Object.keys(sizes).length) { + map[uri.toString()] = sizes; + } else { + delete map[uri.toString()]; + } + await context.workspaceState.update(CsvEditorProvider.columnSizesKey, map); + } + public static getSeparatorForUri(context: vscode.ExtensionContext, uri: vscode.Uri): string | undefined { const map = context.workspaceState.get>(CsvEditorProvider.sepKey, {}); return map[uri.toString()]; diff --git a/src/bounded-csv-reader.ts b/src/bounded-csv-reader.ts new file mode 100644 index 0000000..494c5a8 --- /dev/null +++ b/src/bounded-csv-reader.ts @@ -0,0 +1,220 @@ +import { promises as fs } from 'fs'; +import { TextDecoder } from 'util'; +import Papa from 'papaparse'; + +export const previewLimits = { + readBytes: 64 * 1024, + pageBytes: 512 * 1024, + recordBytes: 256 * 1024, + pageCells: 20000, + columns: 1000, + history: 32 +}; + +export type CsvPreviewPage = { + rows: string[][]; + offset: number; + nextOffset: number; + done: boolean; + version: string; + bytesRead: number; +}; + +export type CsvPreviewReadOptions = { + delimiter: string; + offset?: number; + maxRows?: number; + expectedVersion?: string; + cancelled?: () => boolean; + // Smaller read buffers make boundary handling deterministic in unit tests. + readBytes?: number; +}; + +function version(stat: { dev: number; ino: number; size: number; mtimeMs: number }): string { + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; +} + +export async function readCsvPreviewPage(filePath: string, options: CsvPreviewReadOptions): Promise { + if (options.delimiter.length !== 1 || options.delimiter.charCodeAt(0) > 127 || /[\r\n"\0]/.test(options.delimiter)) { + throw new Error('Large File Preview requires a single ASCII separator. Set a separator override or use the editable CSV view.'); + } + const checkCancelled = () => { + if (options.cancelled?.()) { + throw new Error('Large File Preview was cancelled.'); + } + }; + checkCancelled(); + const handle = await fs.open(filePath, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error('Large File Preview can only read regular files.'); + } + const fileVersion = version(stat); + if (options.expectedVersion && options.expectedVersion !== fileVersion) { + throw new Error('The file changed. Choose Back to Start to reload it.'); + } + const offset = options.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size) { + throw new Error('Invalid preview position. Choose Back to Start.'); + } + const requestedRows = Math.trunc(Number(options.maxRows ?? 1000)); + const maxRows = Number.isFinite(requestedRows) ? Math.min(10000, Math.max(1, requestedRows)) : 1000; + const readSize = Math.min(previewLimits.readBytes, Math.max(1, Math.trunc(options.readBytes ?? previewLimits.readBytes))); + const readBuffer = Buffer.alloc(readSize); + const recordBuffer = Buffer.alloc(previewLimits.recordBytes); + const rows: string[][] = []; + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + const delimiter = options.delimiter.charCodeAt(0); + let position = offset; + let recordStart = offset; + let recordLength = 0; + let recordFields = 1; + let usedBytes = 0; + let usedCells = 0; + let bytesRead = 0; + let state: 'start' | 'plain' | 'quoted' | 'closed' = 'start'; + let pendingCr = false; + let stopped = false; + let nextOffset = offset; + + if (offset === 0) { + const signature = Buffer.alloc(3); + const first = await handle.read(signature, 0, 3, 0); + bytesRead += first.bytesRead; + if ((signature[0] === 0xFF && signature[1] === 0xFE) || (signature[0] === 0xFE && signature[1] === 0xFF)) { + throw new Error('Large File Preview supports UTF-8 files only. Use the normal editor and Reopen with Encoding for this file.'); + } + if (first.bytesRead === 3 && signature.equals(Buffer.from([0xEF, 0xBB, 0xBF]))) { + position = recordStart = 3; + } + } + + const finishRecord = (end: number, terminatorBytes: number) => { + if (usedBytes + recordLength > previewLimits.pageBytes && rows.length > 0) { + stopped = true; + nextOffset = recordStart; + return; + } + let text: string; + try { + text = decoder.decode(recordBuffer.subarray(0, recordLength - terminatorBytes)); + } catch { + throw new Error('Large File Preview supports valid UTF-8 only. Reopen the original file with its source encoding in the normal editor.'); + } + if (text.includes('\0')) { + throw new Error('The file contains NUL bytes. Large File Preview supports UTF-8 text files only.'); + } + // Prefix an empty field so Papa does not strip an intentional U+FEFF at + // the start of a later record when parsing records independently. + const parsed = Papa.parse(`${options.delimiter}${text}`, { delimiter: options.delimiter, dynamicTyping: false }); + if (parsed.errors.some(error => error.type === 'Quotes') || parsed.data.length !== 1) { + throw new Error(`Malformed CSV record at byte ${recordStart}. No partial record was displayed.`); + } + const row = parsed.data[0].slice(1); + if (row.length > previewLimits.columns) { + throw new Error(`A record exceeds the ${previewLimits.columns}-column preview limit. No fields were truncated.`); + } + if (usedCells + row.length > previewLimits.pageCells && rows.length > 0) { + stopped = true; + nextOffset = recordStart; + return; + } + rows.push(row); + usedBytes += recordLength; + usedCells += row.length; + nextOffset = recordStart = end; + recordLength = 0; + recordFields = 1; + state = 'start'; + pendingCr = false; + stopped = rows.length >= maxRows || usedBytes >= previewLimits.pageBytes || usedCells >= previewLimits.pageCells; + }; + + while (!stopped && position < stat.size) { + checkCancelled(); + const read = await handle.read(readBuffer, 0, readBuffer.length, position); + bytesRead += read.bytesRead; + if (read.bytesRead === 0) { + break; + } + for (let index = 0; index < read.bytesRead && !stopped; index++) { + const byte = readBuffer[index]; + if (pendingCr && byte !== 10) { + finishRecord(position, 1); + if (stopped) { + break; + } + } + if (recordLength >= recordBuffer.length) { + throw new Error(`A record exceeds the ${previewLimits.recordBytes / 1024} KiB preview limit. No partial record was displayed.`); + } + recordBuffer[recordLength++] = byte; + position++; + if (pendingCr) { + finishRecord(position, 2); + } else if (state === 'quoted') { + if (byte === 34) { + state = 'closed'; + } + } else if (state === 'closed' && byte === 34) { + state = 'quoted'; + } else if (byte === 13) { + pendingCr = true; + } else if (byte === 10) { + finishRecord(position, 1); + } else if (byte === delimiter) { + recordFields++; + if (recordFields > previewLimits.columns) { + throw new Error(`A record exceeds the ${previewLimits.columns}-column preview limit. No fields were truncated.`); + } + state = 'start'; + } else if (state === 'start' && byte === 34) { + state = 'quoted'; + } else if (state === 'closed') { + if (byte !== 32 && byte !== 9) { + throw new Error(`Malformed quoted field at byte ${position - 1}. No partial record was displayed.`); + } + } else { + state = 'plain'; + } + } + } + if (!stopped && recordLength > 0) { + if (state === 'quoted') { + throw new Error(`Unterminated quoted record at byte ${recordStart}. No partial record was displayed.`); + } + finishRecord(position, pendingCr ? 1 : 0); + } + if (rows.length === 0) { + nextOffset = position; + } + checkCancelled(); + if (version(await fs.stat(filePath)) !== fileVersion || version(await handle.stat()) !== fileVersion) { + throw new Error('The file changed while reading. Choose Back to Start to reload it.'); + } + return { rows, offset, nextOffset, done: nextOffset >= stat.size, version: fileVersion, bytesRead }; + } finally { + await handle.close(); + } +} + +export type PreviewPosition = { offset: number; record: number }; + +export class PreviewHistory { + private positions: PreviewPosition[] = [{ offset: 0, record: 0 }]; + private index = 0; + get current(): PreviewPosition { return this.positions[this.index]; } + get canGoBack(): boolean { return this.index > 0; } + get size(): number { return this.positions.length; } + reset(): void { this.positions = [{ offset: 0, record: 0 }]; this.index = 0; } + previous(): void { if (this.canGoBack) { this.index--; } } + next(position: PreviewPosition): void { + this.positions.splice(this.index + 1); + this.positions.push(position); + if (this.positions.length > previewLimits.history) { + this.positions.shift(); + } + this.index = this.positions.length - 1; + } +} diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..3c934ae --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,86 @@ +import Papa from 'papaparse'; + +const clipboardDelimiterCandidates = ['\t', ',', ';', '|']; + +export function serializeClipboardMatrix(matrix: string[][], delimiter: string): string { + const escapedCharacters = [delimiter, ...clipboardDelimiterCandidates, '"', '\r', '\n', '\uFEFF']; + // Quote each value independently: a complex neighbor must not wrap plain text. + return Papa.unparse(matrix, { + delimiter, + quotes: value => value === '' || value.startsWith(' ') || value.endsWith(' ') || + escapedCharacters.some(character => value.includes(character)), + newline: '\r\n' + }); +} + +// Papa's candidate parsers can mistake separators inside a later quoted field +// for delimiters when the first field is unquoted. Remove candidates that occur +// only inside complete quoted fields, then leave parsing/scoring to Papa. +function clipboardDelimitersOutsideQuotes(text: string, candidates: string[]): string[] { + const tokens = [...candidates].sort((a, b) => b.length - a.length); + const found = new Set(); + let quoted = new Set(); + let inQuotes = false; + let fieldStart = true; + const at = (index: number) => tokens.find(token => text.startsWith(token, index)); + for (let i = 0; i < text.length;) { + const character = text[i]; + if (inQuotes) { + if (character === '"') { + if (text[i + 1] === '"') { i += 2; continue; } + const next = text[i + 1]; + if (next !== undefined && next !== '\r' && next !== '\n' && !at(i + 1)) { + // An incomplete/malformed quoted prefix is ordinary external text. + for (const token of quoted) { found.add(token); } + } + inQuotes = false; + quoted = new Set(); + fieldStart = false; + i++; + } else { + const token = at(i); + if (token) { quoted.add(token); i += token.length; } + else { i++; } + } + continue; + } + if (character === '"' && fieldStart) { + inQuotes = true; + i++; + continue; + } + const token = at(i); + if (token) { + found.add(token); + fieldStart = true; + i += token.length; + } else { + fieldStart = character === '\r' || character === '\n'; + i++; + } + } + // Do not hide genuine separators behind an unterminated literal quote. + if (inQuotes) { for (const token of quoted) { found.add(token); } } + return candidates.filter(candidate => found.has(candidate)); +} + +export function parseClipboardMatrix(text: string, fallbackDelimiter: string): string[][] { + if (!text) { + return []; + } + const candidates = [fallbackDelimiter, ...clipboardDelimiterCandidates].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); + const available = clipboardDelimitersOutsideQuotes(text, candidates); + const parsed = Papa.parse(text, { + dynamicTyping: false, + delimiter: available.length > 1 ? '' : available[0] || fallbackDelimiter, + delimitersToGuess: available + }); + const matrix = parsed.data.map(row => row.map(value => String(value ?? ''))); + // A terminal record separator creates one parser sentinel, not an extra record. + // Preserve explicit blank records, including quoted empty fields. + if (/[\r\n]$/.test(text) && matrix.length > 1 && matrix[matrix.length - 1].length === 1 && matrix[matrix.length - 1][0] === '') { + matrix.pop(); + } + const width = matrix.reduce((max, row) => Math.max(max, row.length), 0); + return matrix.map(row => [...row, ...Array(width - row.length).fill('')]); +} diff --git a/src/column-filters.ts b/src/column-filters.ts new file mode 100644 index 0000000..26221d7 --- /dev/null +++ b/src/column-filters.ts @@ -0,0 +1,197 @@ +import { RE2JS } from 're2js'; +import { FilterDataType, compileFilterDateComparison, compileFilterNumberComparison, parseFilterBoolean } from './filter-values'; + +export type { FilterDataType } from './filter-values'; +export type ColumnFilterOperator = 'contains' | 'notContains' | 'regex' | 'equals' | 'notEquals' | 'greaterThan' | 'greaterThanOrEqual' | 'lessThan' | 'lessThanOrEqual' | 'empty' | 'notEmpty'; +export type ColumnFilter = { column: number; operator: ColumnFilterOperator; value: string; dataType?: FilterDataType }; +export type ColumnFilterOperatorChoice = { operator: ColumnFilterOperator; label: string }; +export type CompiledColumnFilters = (row: readonly string[]) => boolean; +export const MAX_FILTER_VALUE_LENGTH = 4096; +export const MAX_COLUMN_FILTERS = 256; + +const operatorChoices: readonly ColumnFilterOperatorChoice[] = [ + { operator: 'contains', label: 'Contains' }, + { operator: 'notContains', label: 'Does Not Contain' }, + { operator: 'regex', label: 'Regular Expression' }, + { operator: 'equals', label: 'Equals' }, + { operator: 'notEquals', label: 'Does Not Equal' }, + { operator: 'greaterThan', label: 'Greater Than' }, + { operator: 'greaterThanOrEqual', label: 'Greater Than or Equal' }, + { operator: 'lessThan', label: 'Less Than' }, + { operator: 'lessThanOrEqual', label: 'Less Than or Equal' }, + { operator: 'empty', label: 'Is Empty' }, + { operator: 'notEmpty', label: 'Is Not Empty' } +]; +const operators = new Set(operatorChoices.map(choice => choice.operator)); +const dataTypes = new Set(['string', 'number', 'date', 'boolean']); +const orderedOperators = new Set(['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']); + +export function getFilterOperatorChoices(dataType: FilterDataType): readonly ColumnFilterOperatorChoice[] { + return operatorChoices.filter(choice => !orderedOperators.has(choice.operator) || dataType === 'number' || dataType === 'date'); +} + +function compileFilterRegex(value: string): RE2JS { + let pattern = value; + let flags = RE2JS.CASE_INSENSITIVE; + if (value.startsWith('/')) { + let closingSlash = -1; + for (let i = value.length - 1; i > 0; i--) { + if (value[i] !== '/') { + continue; + } + let backslashes = 0; + for (let j = i - 1; j >= 0 && value[j] === '\\'; j--) { + backslashes++; + } + if (backslashes % 2 === 0) { + closingSlash = i; + break; + } + } + if (closingSlash > 0) { + pattern = value.slice(1, closingSlash); + flags = 0; + const seen = new Set(); + for (const flag of value.slice(closingSlash + 1)) { + if (seen.has(flag) || (flag !== 'i' && flag !== 'm' && flag !== 's')) { + throw new Error('Only the i, m, and s flags are supported, without duplicates.'); + } + seen.add(flag); + flags |= flag === 'i' ? RE2JS.CASE_INSENSITIVE : flag === 'm' ? RE2JS.MULTILINE : RE2JS.DOTALL; + } + } + } + return RE2JS.compile(pattern, flags); +} + +function compileValuePredicate(operator: ColumnFilterOperator, query: string, dataType: FilterDataType): (value: string) => boolean { + if (operator === 'empty') { + return value => value === ''; + } + if (operator === 'notEmpty') { + return value => value !== ''; + } + if (operator === 'regex') { + const regex = compileFilterRegex(query); + return value => regex.matcher(value).find(); + } + if (operator === 'contains' || operator === 'notContains') { + const lowerQuery = query.toLowerCase(); + return operator === 'contains' ? value => value.toLowerCase().includes(lowerQuery) : value => !value.toLowerCase().includes(lowerQuery); + } + if (orderedOperators.has(operator) && dataType !== 'number' && dataType !== 'date') { + throw new Error('Ordered comparisons require a number or date column.'); + } + if (dataType === 'string') { + const lowerQuery = query.toLowerCase(); + return operator === 'equals' ? value => value.toLowerCase() === lowerQuery : value => value.toLowerCase() !== lowerQuery; + } + if (dataType === 'number' || dataType === 'date') { + const compare = dataType === 'number' ? compileFilterNumberComparison(query) : compileFilterDateComparison(query); + if (!compare) { + throw new Error(`Enter a valid ${dataType} value.`); + } + return value => { + const comparison = compare(value); + if (comparison === undefined) { + return false; + } + switch (operator) { + case 'equals': return comparison === 0; + case 'notEquals': return comparison !== 0; + case 'greaterThan': return comparison > 0; + case 'greaterThanOrEqual': return comparison >= 0; + case 'lessThan': return comparison < 0; + case 'lessThanOrEqual': return comparison <= 0; + default: return false; + } + }; + } + const threshold = parseFilterBoolean(query); + if (threshold === undefined) { + throw new Error(`Enter a valid ${dataType} value.`); + } + return value => { + const scalar = parseFilterBoolean(value); + if (scalar === undefined) { + return false; + } + return operator === 'equals' ? scalar === threshold : scalar !== threshold; + }; +} + +export function validateColumnFilterInput(operator: ColumnFilterOperator, value: string, dataType: FilterDataType = 'string'): string | undefined { + if (!operators.has(operator) || !dataTypes.has(dataType)) { + return 'Choose a valid filter operator and data type.'; + } + if (typeof value !== 'string' || value.length > MAX_FILTER_VALUE_LENGTH) { + return `Filter values must contain at most ${MAX_FILTER_VALUE_LENGTH} characters.`; + } + try { + compileValuePredicate(operator, value, dataType); + return undefined; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return operator === 'regex' ? `Enter a valid RE2 regular expression: ${detail}` : detail; + } +} + +export function normalizeColumnFilters(raw: unknown, columnCount = Infinity): ColumnFilter[] { + if (!Array.isArray(raw)) { + return []; + } + const normalized: ColumnFilter[] = []; + for (const item of raw.slice(0, MAX_COLUMN_FILTERS)) { + if (!item || typeof item !== 'object') { + continue; + } + const { column, operator, value, dataType } = item as Partial; + if (typeof column !== 'number' || !Number.isSafeInteger(column) || column < 0 || column >= columnCount || !operators.has(operator as ColumnFilterOperator) || typeof value !== 'string' || value.length > MAX_FILTER_VALUE_LENGTH || (dataType !== undefined && !dataTypes.has(dataType))) { + continue; + } + // Invalid query syntax remains visible and removable; compilation fails closed. + normalized.push({ column, operator: operator!, value, ...(dataType === undefined ? {} : { dataType }) }); + } + return normalized; +} + +export function compileColumnFilters(filters: readonly ColumnFilter[]): CompiledColumnFilters { + if (filters.length > MAX_COLUMN_FILTERS) { + return () => false; + } + try { + const predicates = filters.map(filter => { + const dataType = filter.dataType ?? 'string'; + if (!Number.isSafeInteger(filter.column) || filter.column < 0 || !operators.has(filter.operator) || !dataTypes.has(dataType) || typeof filter.value !== 'string' || filter.value.length > MAX_FILTER_VALUE_LENGTH) { + throw new Error('Invalid column filter.'); + } + const matches = compileValuePredicate(filter.operator, filter.value, dataType); + return (row: readonly string[]) => matches(String(row[filter.column] ?? '')); + }); + return row => predicates.every(matches => matches(row)); + } catch { + return () => false; + } +} + +export function rowMatchesFilters(row: readonly string[], filters: readonly ColumnFilter[]): boolean { + return compileColumnFilters(filters)(row); +} + +export function visibleSourceRows(data: readonly string[][], hiddenRows: number, header: boolean, filters: readonly ColumnFilter[]): number[] { + const start = Math.min(Math.max(0, hiddenRows), data.length); + const rows: number[] = []; + const matches = compileColumnFilters(filters); + for (let row = start; row < data.length; row++) { + if ((header && row === start) || matches(data[row])) { + rows.push(row); + } + } + return rows; +} + +// new column index -> original index; -1 denotes an inserted empty column. +export function remapColumnFilters(filters: readonly ColumnFilter[], columnOrder: readonly number[]): ColumnFilter[] { + return filters.flatMap(filter => columnOrder.flatMap((oldColumn, column) => + oldColumn === filter.column ? [{ ...filter, column }] : [])); +} diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..e890437 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,11 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; +import { openLargeFilePreview } from './large-file-preview'; +import { toggleCsvEditor } from './editor-switch'; +import type { EditorSwitchRouting } from './editor-associations'; +import { compareFilesAsTable } from './table-comparison'; +import { saveActiveUntitledCsvEditor } from './untitled-editors'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -19,11 +24,61 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref CsvEditorProvider.editors.forEach(ed => ed.refresh()); } -export function registerCsvCommands(context: vscode.ExtensionContext) { +async function changeFrozenPaneCount(axis: 'rows' | 'columns') { + const active = CsvEditorProvider.getActiveProvider(); + if (!active) { + vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); + return; + } + const current = active.getCurrentFrozenPanes()[axis]; + const input = await vscode.window.showInputBox({ + prompt: `Number of leading visible ${axis} to freeze (0 unfreezes)`, + value: String(current), + validateInput: value => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? undefined : 'Enter a non-negative whole number.' + }); + if (input !== undefined && /^\d+$/.test(input) && Number.isSafeInteger(Number(input))) { + await active.setFrozenPanes({ [axis]: Number(input) }); + } +} + +async function toggleCsvEnabled(synchronizeRouting: () => Promise): Promise { + const config = vscode.workspace.getConfiguration('csv'); + const inspected = config.inspect('enabled'); + const target = inspected?.workspaceValue !== undefined ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global; + const previous = target === vscode.ConfigurationTarget.Workspace ? inspected?.workspaceValue : inspected?.globalValue; + const next = !config.get('enabled', true); + await config.update('enabled', next, target); + try { + await synchronizeRouting(); + } catch (error) { + // Do not report Off while VS Code still routes searches into the custom view. + const current = config.inspect('enabled'); + const value = target === vscode.ConfigurationTarget.Workspace ? current?.workspaceValue : current?.globalValue; + if (value === next) { + await config.update('enabled', previous, target); + try { await synchronizeRouting(); } catch (restoreError) { console.error('CSV: routing rollback failed', restoreError); } + } + console.error('CSV: extension toggle was not applied', error); + return; + } + vscode.window.showInformationMessage(`CSV extension ${next ? 'enabled' : 'disabled'}.`); + CsvEditorProvider.editors.slice().forEach(ed => ed.refresh()); +} + +export function registerCsvCommands(context: vscode.ExtensionContext, synchronizeRouting: () => Promise = async () => {}, prepareEditorRouting?: (uri: vscode.Uri, target: 'default' | 'csv.editor') => Promise) { + let pendingToggle: Promise = Promise.resolve(); context.subscriptions.push( - vscode.commands.registerCommand('csv.toggleExtension', () => - toggleBooleanConfig('enabled', true, 'CSV extension') - ), + vscode.commands.registerCommand('csv.changeFrozenRows', () => changeFrozenPaneCount('rows')), + vscode.commands.registerCommand('csv.changeFrozenColumns', () => changeFrozenPaneCount('columns')), + vscode.commands.registerCommand('csv.openLargeFilePreview', openLargeFilePreview), + vscode.commands.registerCommand('csv.toggleEditor', () => toggleCsvEditor(prepareEditorRouting)), + vscode.commands.registerCommand('csv.compareFilesAsTable', (resource?: vscode.Uri, selected?: vscode.Uri[]) => compareFilesAsTable(context, resource, selected)), + vscode.commands.registerCommand('csv.saveUntitled', saveActiveUntitledCsvEditor), + vscode.commands.registerCommand('csv.toggleExtension', () => { + const next = pendingToggle.catch(() => {}).then(() => toggleCsvEnabled(synchronizeRouting)); + pendingToggle = next; + return next; + }), vscode.commands.registerCommand('csv.toggleClickableLinks', () => toggleBooleanConfig('clickableLinks', true, 'CSV clickable links') ), @@ -114,23 +169,29 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { const uri = active.getDocumentUri(); const current = CsvEditorProvider.getHiddenRowsForUri(context, uri); const input = await vscode.window.showInputBox({ - prompt: 'Hide first N rows (per file)', + prompt: 'Hide first N rows for this file (blank inherits csv.defaultIgnoreRows)', value: String(current), - validateInput: (val: string) => (/^\d+$/.test(val) ? undefined : 'Enter a non-negative integer') + validateInput: (val: string) => (val.trim() === '' || (/^\d+$/.test(val) && Number.isSafeInteger(Number(val))) ? undefined : 'Enter a non-negative integer or leave blank to inherit') }); if (input === undefined) { return; } - const n = parseInt(input, 10); + if (input.trim() !== '' && (!/^\d+$/.test(input) || !Number.isSafeInteger(Number(input)))) { return; } + const n = input.trim() === '' ? undefined : Number(input); await CsvEditorProvider.setHiddenRowsForUri(context, uri, n); // Refresh only editors showing this URI CsvEditorProvider.editors .filter(ed => ed.getDocumentUri().toString() === uri.toString()) - .forEach(ed => ed.refresh()); - vscode.window.showInformationMessage(`CSV: Hiding first ${n} row(s) for this file.`); + .forEach(ed => ed.refreshViewSafely()); + vscode.window.showInformationMessage(n === undefined ? 'CSV: Hidden rows now inherit csv.defaultIgnoreRows for this file.' : `CSV: Hiding first ${n} row(s) for this file.`); }), - vscode.commands.registerCommand('csv.changeEncoding', async () => { + vscode.commands.registerCommand('csv.changeEncoding', async (requestedUri?: vscode.Uri) => { const active = CsvEditorProvider.getActiveProvider(); - if (!active) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } - const uri = active.getDocumentUri(); + const uri = requestedUri instanceof vscode.Uri ? requestedUri : active?.getDocumentUri(); + if (!uri) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } + if (uri.scheme === 'untitled') { + vscode.window.showInformationMessage('Save this document before changing its file encoding.'); + return; + } + // Close any existing tabs for this URI so we can reuse the slot cleanly try { @@ -144,10 +205,14 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { }); if (toClose.length) { console.log(`[CSV(encoding)]: closing ${toClose.length} tab(s) for ${uri.fsPath}`); - await vscode.window.tabGroups.close(toClose); + const closed = await vscode.window.tabGroups.close(toClose); + if (!closed) { + return; + } } } catch (e) { console.warn('[CSV(encoding)]: failed to close existing tabs', e); + return; } // Open the default text editor in-place and invoke the built-in encoding picker diff --git a/src/csv-table-diff.ts b/src/csv-table-diff.ts new file mode 100644 index 0000000..641b1e5 --- /dev/null +++ b/src/csv-table-diff.ts @@ -0,0 +1,234 @@ +import Papa from 'papaparse'; + +export const comparisonLimits = { + sourceBytes: 10 * 1024 * 1024, + textBytes: 5 * 1024 * 1024, + rows: 10000, + cells: 100000, + columns: 1000, + cellChars: 64 * 1024, + recordChars: 256 * 1024, + alignmentCells: 1000000, + replacementCells: 1000000, + alignmentDepth: 20, + pageRows: 200, + pageCells: 20000, + pageChars: 512 * 1024 +}; + +export type ComparedRecord = { + original?: number; + modified?: number; + kind: 'equal' | 'changed' | 'added' | 'removed'; + changedColumns: number[]; +}; +export type CsvTableDiff = { + originalRows: string[][]; + modifiedRows: string[][]; + records: ComparedRecord[]; + approximate: boolean; + added: number; + removed: number; + changed: number; +}; + +export function parseComparisonRows(text: string, delimiter: string): string[][] { + if (Buffer.byteLength(text, 'utf8') > comparisonLimits.textBytes) { + throw new Error('Table comparison supports at most 5 MiB of decoded text per file. Use a text diff for larger files.'); + } + const parsed = Papa.parse(text, { delimiter, dynamicTyping: false }); + if (parsed.errors.some(error => error.type === 'Quotes')) { + throw new Error('The input contains malformed quoted CSV. Correct it before comparing as a table.'); + } + const rows = parsed.data; + if (/[\r\n]$/.test(text) && rows.length && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0] === '') { + rows.pop(); // Papa's EOF marker is not an additional logical record. + } + if (rows.length > comparisonLimits.rows) { + throw new Error('Table comparison supports at most 10,000 records per file. Use a text diff for larger files.'); + } + let cells = 0; + for (const row of rows) { + cells += row.length; + if (row.length > comparisonLimits.columns || cells > comparisonLimits.cells) { + throw new Error('Table comparison supports at most 1,000 columns and 100,000 cells per file. No fields were truncated.'); + } + if (row.some(value => value.length > comparisonLimits.cellChars) || row.reduce((sum, value) => sum + value.length, 0) > comparisonLimits.recordChars) { + throw new Error('A value or record exceeds the table comparison display limit. No content was truncated.'); + } + } + return rows; +} + +export function compareCsvRows(originalRows: string[][], modifiedRows: string[][]): CsvTableDiff { + const original = originalRows.map(row => JSON.stringify(row)); + const modified = modifiedRows.map(row => JSON.stringify(row)); + const matches: Array<[number, number]> = []; + let budget = comparisonLimits.alignmentCells; + let approximate = false; + + const uniqueAnchors = (a0: number, a1: number, b0: number, b1: number): Array<[number, number]> => { + const indices = (values: string[], start: number, end: number) => { + const map = new Map(); + for (let index = start; index < end; index++) { + map.set(values[index], map.has(values[index]) ? -1 : index); + } + return map; + }; + const left = indices(original, a0, a1); + const right = indices(modified, b0, b1); + const candidates: Array<[number, number]> = []; + for (let index = a0; index < a1; index++) { + const other = right.get(original[index]); + if (left.get(original[index]) === index && other !== undefined && other >= 0) { + candidates.push([index, other]); + } + } + const tails: number[] = []; + const predecessors = Array(candidates.length).fill(-1); + for (let index = 0; index < candidates.length; index++) { + let low = 0, high = tails.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (candidates[tails[middle]][1] < candidates[index][1]) { low = middle + 1; } else { high = middle; } + } + predecessors[index] = low > 0 ? tails[low - 1] : -1; + tails[low] = index; + } + const anchors: Array<[number, number]> = []; + for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = predecessors[index]) { + anchors.push(candidates[index]); + } + return anchors.reverse(); + }; + + const align = (a0: number, a1: number, b0: number, b1: number, depth: number): void => { + while (a0 < a1 && b0 < b1 && original[a0] === modified[b0]) { + matches.push([a0++, b0++]); + } + const suffix: Array<[number, number]> = []; + while (a0 < a1 && b0 < b1 && original[a1 - 1] === modified[b1 - 1]) { + suffix.push([--a1, --b1]); + } + if (a0 < a1 && b0 < b1) { + const height = a1 - a0, width = b1 - b0; + const cost = (height + 1) * (width + 1); + if (cost <= budget) { + budget -= cost; + const stride = width + 1; + const lengths = new Uint16Array(cost); + for (let a = height - 1; a >= 0; a--) { + for (let b = width - 1; b >= 0; b--) { + lengths[a * stride + b] = original[a0 + a] === modified[b0 + b] + ? lengths[(a + 1) * stride + b + 1] + 1 + : Math.max(lengths[(a + 1) * stride + b], lengths[a * stride + b + 1]); + } + } + let a = 0, b = 0; + while (a < height && b < width) { + if (original[a0 + a] === modified[b0 + b]) { matches.push([a0 + a++, b0 + b++]); } + else if (lengths[(a + 1) * stride + b] >= lengths[a * stride + b + 1]) { a++; } + else { b++; } + } + } else if (depth < comparisonLimits.alignmentDepth) { + const anchors = uniqueAnchors(a0, a1, b0, b1); + if (!anchors.length) { + approximate = true; + } else { + for (const [a, b] of anchors) { + align(a0, a, b0, b, depth + 1); + matches.push([a, b]); + a0 = a + 1; b0 = b + 1; + } + align(a0, a1, b0, b1, depth + 1); + } + } else { + approximate = true; + } + } + matches.push(...suffix.reverse()); + }; + align(0, original.length, 0, modified.length, 0); + + const result: CsvTableDiff = { originalRows, modifiedRows, records: [], approximate, added: 0, removed: 0, changed: 0 }; + const append = (a: number | undefined, b: number | undefined) => { + const before = a === undefined ? undefined : originalRows[a]; + const after = b === undefined ? undefined : modifiedRows[b]; + const changedColumns: number[] = []; + for (let col = 0; col < Math.max(before?.length ?? 0, after?.length ?? 0); col++) { + if (!before || !after || col >= before.length || col >= after.length || before[col] !== after[col]) { + changedColumns.push(col); + } + } + const kind = a === undefined ? 'added' : b === undefined ? 'removed' : changedColumns.length ? 'changed' : 'equal'; + result.records.push({ original: a, modified: b, kind, changedColumns }); + if (kind !== 'equal') { result[kind]++; } + }; + let replacementBudget = comparisonLimits.replacementCells; + const appendGap = (a0: number, a1: number, b0: number, b1: number) => { + const height = a1 - a0, width = b1 - b0; + const columns = Math.max(1, ...originalRows.slice(a0, a1).map(row => row.length), ...modifiedRows.slice(b0, b1).map(row => row.length)); + const work = height * width * columns; + if (height && width && work <= replacementBudget && (height + 1) * (width + 1) <= comparisonLimits.replacementCells) { + replacementBudget -= work; + const stride = width + 1; + const costs = new Float64Array((height + 1) * stride); + const choices = new Uint8Array(height * width); + for (let a = 0; a <= height; a++) { costs[a * stride + width] = height - a; } + for (let b = 0; b <= width; b++) { costs[height * stride + b] = width - b; } + for (let a = height - 1; a >= 0; a--) { + for (let b = width - 1; b >= 0; b--) { + const before = originalRows[a0 + a], after = modifiedRows[b0 + b]; + let equal = 0; + for (let col = 0; col < Math.min(before.length, after.length); col++) { + if (before[col] === after[col]) { equal++; } + } + // Shared fields favor pairing a modified record over an adjacent insertion/deletion. + const replace = 2 * (1 - equal / Math.max(1, before.length, after.length)) + costs[(a + 1) * stride + b + 1]; + const remove = 1 + costs[(a + 1) * stride + b]; + const add = 1 + costs[a * stride + b + 1]; + const choice = replace <= remove && replace <= add ? 0 : remove <= add ? 1 : 2; + choices[a * width + b] = choice; + costs[a * stride + b] = choice === 0 ? replace : choice === 1 ? remove : add; + } + } + let a = 0, b = 0; + while (a < height || b < width) { + const choice = a === height ? 2 : b === width ? 1 : choices[a * width + b]; + if (choice === 0) { append(a0 + a++, b0 + b++); } + else if (choice === 1) { append(a0 + a++, undefined); } + else { append(undefined, b0 + b++); } + } + } else { + if (height && width) { result.approximate = true; } + for (let index = 0; index < Math.max(height, width); index++) { + append(a0 + index < a1 ? a0 + index : undefined, b0 + index < b1 ? b0 + index : undefined); + } + } + }; + let a = 0, b = 0; + for (const [nextA, nextB] of [...matches, [original.length, modified.length]]) { + appendGap(a, nextA, b, nextB); + if (nextA < original.length && nextB < modified.length) { append(nextA, nextB); } + a = nextA + 1; b = nextB + 1; + } + return result; +} + +export function comparisonPages(diff: CsvTableDiff): Array<{ start: number; end: number }> { + const pages: Array<{ start: number; end: number }> = []; + let start = 0, cells = 0, chars = 0; + for (let index = 0; index < diff.records.length; index++) { + const record = diff.records[index]; + const before = record.original === undefined ? [] : diff.originalRows[record.original]; + const after = record.modified === undefined ? [] : diff.modifiedRows[record.modified]; + const nextCells = Math.max(before.length, after.length) * 2 + 2; + const nextChars = [...before, ...after].reduce((sum, value) => sum + value.length, 0); + if (index > start && (index - start >= comparisonLimits.pageRows || cells + nextCells > comparisonLimits.pageCells || chars + nextChars > comparisonLimits.pageChars)) { + pages.push({ start, end: index }); start = index; cells = 0; chars = 0; + } + cells += nextCells; chars += nextChars; + } + pages.push({ start, end: diff.records.length }); + return pages; +} diff --git a/src/editor-associations.ts b/src/editor-associations.ts new file mode 100644 index 0000000..c98ed74 --- /dev/null +++ b/src/editor-associations.ts @@ -0,0 +1,390 @@ +import type * as vscode from 'vscode'; +import { randomBytes } from 'crypto'; +import { performance } from 'perf_hooks'; + +const journalPrefix = 'csv.nativeEditorRouting.'; +type Journal = { created: number; state: Ownership }; +export const csvEditorSelectors = ['*.csv', '*.tsv', '*.tab', '*.psv'] as const; +const csvEditor = 'csv.editor'; +type Associations = Record; +type OwnedEntry = { previous?: string; hadPrevious: boolean; applied: string; textView?: boolean; owner?: string }; +type Ownership = { version: 1; entries: Record; ignored: string[]; released: Record; containerAbsent: boolean }; +type Scope = 'global' | 'workspace'; + +const has = (object: object, key: string) => Object.prototype.hasOwnProperty.call(object, key); +const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const put = (object: Record, key: string, value: T): Record => ({ ...object, [key]: value }); +function dictionary(value: unknown): Associations { + if (value === undefined) { return {}; } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('workbench.editorAssociations must be an object before CSV can change its routing.'); + } + return { ...value as Associations }; +} +function ownership(raw: unknown): Ownership { + const empty: Ownership = { version: 1, entries: {}, ignored: [], released: {}, containerAbsent: false }; + if (!raw || typeof raw !== 'object') { return empty; } + const value = raw as Ownership; + if (value.version !== 1 || !value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries) || !Array.isArray(value.ignored)) { return empty; } + const entries: Record = {}; + for (const [pattern, entry] of Object.entries(value.entries)) { + if (!entry || typeof entry !== 'object' || typeof entry.hadPrevious !== 'boolean' || + (entry.previous !== undefined && typeof entry.previous !== 'string') || + (entry.applied !== 'default' && entry.applied !== csvEditor)) { continue; } + Object.defineProperty(entries, pattern, { value: { ...entry }, enumerable: true, configurable: true, writable: true }); + } + const released = Object.fromEntries(Object.entries(value.released || {}).filter(([, token]) => typeof token === 'string')); + return { version: 1, entries, ignored: value.ignored.filter(pattern => typeof pattern === 'string'), released, containerAbsent: value.containerAbsent === true }; +} +function recovery(storage: vscode.Memento): { state: Ownership; journals: Array<{ key: string; value: Journal }> } { + const journals = storage.keys().filter(key => key.startsWith(journalPrefix)).map(key => ({ key, value: storage.get(key)! })) + .filter(({ value }) => value && typeof value.created === 'number' && value.state?.version === 1) + .sort((a, b) => b.value.created - a.value.created || b.key.localeCompare(a.key)); + const state = ownership(undefined); + state.containerAbsent = journals[0]?.value.state.containerAbsent ?? false; + const decided = new Set(); + for (const journal of journals) { + const stored = ownership(journal.value.state); + for (const [pattern, entry] of Object.entries(stored.entries)) { + if (!decided.has(pattern)) { + Object.defineProperty(state.entries, pattern, { value: entry, enumerable: true, configurable: true, writable: true }); + decided.add(pattern); + } + } + for (const pattern of stored.ignored) { + if (!decided.has(pattern)) { state.ignored.push(pattern); decided.add(pattern); } + } + for (const [pattern, token] of Object.entries(stored.released)) { + if (!decided.has(pattern)) { state.released[pattern] = token; decided.add(pattern); } + } + } + return { state, journals }; +} +function observe(current: Associations, stored: Ownership): { original: Associations; state: Ownership } { + let original = { ...current }, entries = { ...stored.entries }; + const ignored = new Set(stored.ignored); + let containerAbsent = stored.containerAbsent; + for (const [pattern, entry] of Object.entries(entries)) { + if (current[pattern] !== entry.applied) { + // An intervening user edit owns this key for the rest of this disabled period. + delete entries[pattern]; + ignored.add(pattern); + containerAbsent = false; + continue; + } + if (entry.hadPrevious) { original = put(original, pattern, entry.previous); } + else { delete original[pattern]; } + } + return { original, state: { ...stored, entries, ignored: [...ignored], containerAbsent } }; +} +function desiredNative(original: Associations): Map { + const external = Object.entries(original).filter(([, editor]) => typeof editor === 'string' && editor !== csvEditor); + // These patterns cover every CSV-like basename. Their explicit editor choice + // already outranks the contributed editor, so adding *.csv would change it. + const universal = new Set(['*', '**', '**/*', '*.*', '**/*.*']); + const hasUniversal = external.some(([pattern]) => universal.has(pattern)); + const patterns = new Set(Object.keys(original).filter(pattern => original[pattern] === csvEditor)); + for (const selector of csvEditorSelectors) { + if (has(original, selector) || hasUniversal) { continue; } + const extension = selector.slice(1); + const overlaps = external.filter(([pattern]) => { + if (pattern.length > selector.length || !/[*?{[]/.test(pattern)) { return false; } + const suffix = pattern.split(/[*?}\]]/).pop() || ''; + return !suffix || extension.endsWith(suffix); + }); + if (overlaps.length) { + throw new Error(`CSV preserved overlapping editor associations (${overlaps.map(([pattern]) => pattern).join(', ')}). Add an explicit ${selector} choice before retrying the toggle.`); + } + patterns.add(selector); + } + return new Map([...patterns].map(pattern => [pattern, 'default'])); +} +function plan(current: Associations, absent: boolean, stored: Ownership, desired: Map, enabled: boolean) { + let next = { ...current }, entries = { ...stored.entries }; + const ignored = enabled ? new Set() : new Set(stored.ignored); + const released = { ...stored.released }; + if (enabled) { for (const pattern of stored.ignored) { released[pattern] = ''; } } + const beforeWrite = { ...stored, entries: { ...entries }, ignored: [...ignored], released: { ...released } }; + if (!Object.keys(entries).length) { beforeWrite.containerAbsent = absent; } + for (const [pattern, entry] of Object.entries(entries)) { + if (desired.get(pattern) === entry.applied) { continue; } + if (current[pattern] === entry.applied) { + if (entry.hadPrevious) { next = put(next, pattern, entry.previous); } + else { delete next[pattern]; } + } + delete entries[pattern]; + released[pattern] = ''; + } + for (const [pattern, value] of desired) { + if (ignored.has(pattern) || next[pattern] === value) { continue; } + // Existing associations to another editor belong to that editor/user. + if (has(next, pattern) && next[pattern] !== csvEditor && !stored.entries[pattern]) { continue; } + const previous = next[pattern]; + const entry: OwnedEntry = { hadPrevious: has(next, pattern), previous: typeof previous === 'string' ? previous : undefined, applied: value }; + entries = put(entries, pattern, entry); + delete released[pattern]; + delete beforeWrite.released[pattern]; + beforeWrite.entries = put(beforeWrite.entries, pattern, entry); + next = put(next, pattern, value); + } + const conflicts = [...desired].filter(([pattern, value]) => ignored.has(pattern) && current[pattern] !== value); + if (conflicts.length) { + throw new Error(`CSV preserved your changed editor associations (${conflicts.map(([pattern]) => pattern).join(', ')}). Remove or change those CSV editor choices before retrying the toggle.`); + } + const finalState: Ownership = { ...beforeWrite, entries, ignored: [...ignored], released }; + const removeContainer = !Object.keys(next).length && beforeWrite.containerAbsent; + return { next, value: removeContainer ? undefined : next, beforeWrite, finalState }; +} + +export type EditorSwitchRouting = { supported: boolean; limitation?: string; commit(): Promise; rollback(): Promise }; +export function exactFileEditorPattern(uri: vscode.Uri): string | undefined { + // The workbench matches scheme:path, case-insensitively, and omits authority. + // Do not turn a remote/virtual URI into a wider preference for other hosts. + if (uri.scheme !== 'file' || uri.authority || uri.query || uri.fragment) { return undefined; } + return `${uri.scheme}:${uri.path}`.replace(/[\[\]*?{}]/g, character => `[${character}]`); +} + +/** Reversible native routing uses the existing toggle; no document text is stored. */ +export class CsvEditorAssociationRouting implements vscode.Disposable { + private pending: Promise = Promise.resolve(); + private disposed = false; + constructor(private readonly context: vscode.ExtensionContext, private readonly api: typeof vscode) {} + dispose(): void { this.disposed = true; } + synchronize(): Promise { + const next = this.pending.catch(() => {}).then(() => this.synchronizeOnce()); + this.pending = next; + return next; + } + prepareEditorSwitch(uri: vscode.Uri, target: 'default' | 'csv.editor'): Promise { + if (target === csvEditor) { + // Keep Text routing until its replacement table is genuinely usable. + return Promise.resolve({ supported: true, rollback: async () => {}, commit: () => { + const commit = this.pending.catch(() => {}).then(async () => { + // A prior no-workspace choice can coexist with a workspace override. + for (let scope = 0; scope < 2; scope++) { await this.prepareEditorSwitchOnce(uri, csvEditor); } + }); + this.pending = commit; + return commit; + } }); + } + const next = this.pending.catch(() => {}).then(() => this.prepareEditorSwitchOnce(uri, target)); + this.pending = next.then(() => {}, () => {}); + return next; + } + private async prepareEditorSwitchOnce(uri: vscode.Uri, target: 'default' | 'csv.editor'): Promise { + const pattern = exactFileEditorPattern(uri); + const unsupported = (limitation: string): EditorSwitchRouting => ({ supported: false, limitation, commit: async () => {}, rollback: async () => {} }); + if (!pattern) { + return unsupported('CSV: This view can switch, but VS Code cannot isolate this URI in editor associations. Turn CSV off in this workspace before using Find in Files in its text view.'); + } + if (this.disposed) { throw new Error('CSV: Editor routing is no longer active.'); } + const hasWorkspace = !!this.api.workspace.workspaceFile || !!this.api.workspace.workspaceFolders?.length; + let scope: Scope = hasWorkspace ? 'workspace' : 'global'; + const setting = this.api.workspace.getConfiguration('workbench').inspect('editorAssociations'); + if (target === 'csv.editor' && !recovery(this.context.workspaceState).state.entries[pattern]?.textView && + recovery(this.context.globalState).state.entries[pattern]?.textView) { scope = 'global'; } + const storage = scope === 'global' ? this.context.globalState : this.context.workspaceState; + const raw = scope === 'global' ? setting?.globalValue : setting?.workspaceValue; + const current = dictionary(raw); + const stored = recovery(storage).state; + const observed = observe(current, stored); + if (!equal(stored, observed.state)) { await this.recordObservedOwnership(scope, current, raw === undefined, observed.state); } + const previousEntry = observed.state.entries[pattern] && { ...observed.state.entries[pattern] }; + const hadPrevious = has(current, pattern), previousValue = current[pattern]; + const token = randomBytes(12).toString('hex'); + const desired = new Map(Object.entries(observed.state.entries).map(([key, entry]) => [key, entry.applied])); + if (target === 'default') { + const inherited = { ...dictionary(setting?.defaultValue), ...(scope === 'workspace' ? dictionary(setting?.globalValue) : {}) }; + if (!has(current, pattern) && typeof inherited[pattern] === 'string' && inherited[pattern] !== csvEditor && inherited[pattern] !== 'default') { + return unsupported('CSV: Your inherited editor association has been preserved. The view can switch; turn CSV off before using Find in Files if that association opens another editor.'); + } + if (!previousEntry && current[pattern] === 'default') { return { supported: true, commit: async () => {}, rollback: async () => {} }; } + if (observed.state.ignored.includes(pattern) || (has(current, pattern) && current[pattern] !== csvEditor && !previousEntry)) { + return unsupported('CSV: Your editor association has been preserved. The view can switch; turn CSV off before using Find in Files if that association opens another editor.'); + } + desired.set(pattern, 'default'); + } else { + if (!previousEntry?.textView) { return { supported: true, commit: async () => {}, rollback: async () => {} }; } + desired.delete(pattern); + } + const change = plan(current, raw === undefined, observed.state, desired, false); + if (target === 'default') { + const entry = { ...change.finalState.entries[pattern], textView: true, owner: token }; + change.beforeWrite.entries[pattern] = { ...entry }; + change.finalState.entries[pattern] = entry; + } else { change.finalState.released[pattern] = token; } + const config = this.api.workspace.getConfiguration('csv'); + const enabled = config.get('enabled', true); + const stillCurrent = () => !this.disposed && this.api.workspace.getConfiguration('csv').get('enabled', true) === enabled; + const readCurrent = () => { + const latest = this.api.workspace.getConfiguration('workbench').inspect('editorAssociations'); + return dictionary(scope === 'global' ? latest?.globalValue : latest?.workspaceValue); + }; + const expectedHas = has(change.next, pattern), expectedValue = change.next[pattern]; + const rollbackOnce = async () => { + const latest = readCurrent(); + const stored = recovery(storage).state; + const recovered = observe(latest, stored); + if (!equal(stored, recovered.state)) { await this.recordObservedOwnership(scope, latest, false, recovered.state, true); } + const decision = recovered.state.entries[pattern]?.owner ?? recovered.state.released[pattern]; + if (decision !== token || has(latest, pattern) !== expectedHas || latest[pattern] !== expectedValue) { return; } + const nextMap = { ...latest }; + if (hadPrevious) { Object.defineProperty(nextMap, pattern, { value: previousValue, enumerable: true, configurable: true, writable: true }); } + else { delete nextMap[pattern]; } + const finalState: Ownership = { ...recovered.state, entries: { ...recovered.state.entries }, released: { ...recovered.state.released } }; + if (previousEntry) { finalState.entries[pattern] = previousEntry; delete finalState.released[pattern]; } + else { delete finalState.entries[pattern]; finalState.released[pattern] = randomBytes(12).toString('hex'); } + const beforeWrite = previousEntry ? finalState : recovered.state; + await this.apply(scope, latest, { + next: nextMap, value: !Object.keys(nextMap).length && change.beforeWrite.containerAbsent ? undefined : nextMap, + beforeWrite, finalState + }, () => true, true); + }; + try { + await this.apply(scope, current, change, stillCurrent); + if (!stillCurrent()) { throw new Error('CSV: Editor routing changed while switching views.'); } + if (has(readCurrent(), pattern) !== expectedHas || readCurrent()[pattern] !== expectedValue) { + throw new Error('CSV: Could not prepare the file’s editor routing.'); + } + } catch (error) { + await rollbackOnce(); + throw error; + } + return { + supported: true, commit: async () => {}, + rollback: () => { + const rollback = this.pending.catch(() => {}).then(rollbackOnce); + this.pending = rollback; + return rollback; + } + }; + } + private async recordObservedOwnership(scope: Scope, current: Associations, absent: boolean, observed: Ownership, cleanup = false): Promise { + const desired = new Map(Object.entries(observed.entries).map(([pattern, entry]) => [pattern, entry.applied])); + await this.apply(scope, current, plan(current, absent, observed, desired, false), () => cleanup || !this.disposed, cleanup); + } + private async synchronizeOnce(): Promise { + if (this.disposed) { return; } + const workbench = this.api.workspace.getConfiguration('workbench'); + const setting = workbench.inspect('editorAssociations'); + const enabledSetting = this.api.workspace.getConfiguration('csv').inspect('enabled'); + const globalEnabled = enabledSetting?.globalValue ?? enabledSetting?.defaultValue ?? true; + const effectiveEnabled = this.api.workspace.getConfiguration('csv').get('enabled', true); + const hasWorkspace = !!this.api.workspace.workspaceFile || !!this.api.workspace.workspaceFolders?.length; + const defaults = dictionary(setting?.defaultValue); + const globalCurrent = dictionary(setting?.globalValue); + const globalRecovery = recovery(this.context.globalState); + const globalObserved = observe(globalCurrent, globalRecovery.state); + const globalOriginal = { ...defaults, ...globalObserved.original }; + const globalDesired = (hasWorkspace ? globalEnabled : effectiveEnabled) ? new Map() : desiredNative(globalOriginal); + for (const [pattern, entry] of Object.entries(globalObserved.state.entries)) { + if (entry.textView) { globalDesired.set(pattern, 'default'); } + } + const globalPlan = plan(globalCurrent, setting?.globalValue === undefined, globalObserved.state, globalDesired, globalEnabled); + const stillCurrent = () => { + const config = this.api.workspace.getConfiguration('csv'); + const global = config.inspect('enabled'); + return (global?.globalValue ?? global?.defaultValue ?? true) === globalEnabled && config.get('enabled', true) === effectiveEnabled; + }; + await this.apply('global', globalCurrent, globalPlan, stillCurrent); + if (this.disposed || !stillCurrent()) { return; } + if (!hasWorkspace) { this.verifyEffectiveRouting(effectiveEnabled); return; } + + const fresh = workbench.inspect('editorAssociations'); + const workspaceCurrent = dictionary(fresh?.workspaceValue); + const observed = observe(workspaceCurrent, recovery(this.context.workspaceState).state); + const desired = new Map(); + if (!effectiveEnabled) { + const patterns = new Set(desiredNative({ ...globalOriginal, ...observed.original }).keys()); + for (const pattern of patterns) { + if (has(observed.original, pattern)) { + if (observed.original[pattern] === csvEditor) { desired.set(pattern, 'default'); } + } else if (({ ...defaults, ...globalPlan.next })[pattern] === undefined || ({ ...defaults, ...globalPlan.next })[pattern] === csvEditor) { + desired.set(pattern, 'default'); + } + } + } else { + // A workspace explicitly enabled above global Off must counter only our + // inherited temporary native bindings, leaving other editor choices alone. + for (const [pattern, entry] of Object.entries(globalPlan.finalState.entries)) { + if (!entry.textView && !has(observed.original, pattern) && globalPlan.next[pattern] === entry.applied) { + desired.set(pattern, entry.hadPrevious ? entry.previous! : csvEditor); + } + } + } + for (const [pattern, entry] of Object.entries(observed.state.entries)) { + if (entry.textView) { desired.set(pattern, 'default'); } + } + await this.apply('workspace', workspaceCurrent, + plan(workspaceCurrent, fresh?.workspaceValue === undefined, observed.state, desired, effectiveEnabled), stillCurrent); + if (!this.disposed && stillCurrent()) { this.verifyEffectiveRouting(effectiveEnabled); } + } + private verifyEffectiveRouting(enabled: boolean): void { + if (enabled) { return; } + const effective = this.api.workspace.getConfiguration('workbench').get('editorAssociations', {}); + const blocked = Object.keys(effective).filter(pattern => effective[pattern] === csvEditor); + if (blocked.length) { + throw new Error(`CSV could not disable its effective editor associations (${blocked.join(', ')}). A policy or another settings scope still selects CSV.`); + } + } + private async apply(scope: Scope, previous: Associations, change: ReturnType, stillCurrent: () => boolean, cleanup = false): Promise { + const storage = scope === 'global' ? this.context.globalState : this.context.workspaceState; + const target = scope === 'global' ? this.api.ConfigurationTarget.Global : this.api.ConfigurationTarget.Workspace; + const current = () => { + const setting = this.api.workspace.getConfiguration('workbench').inspect('editorAssociations'); + return dictionary(scope === 'global' ? setting?.globalValue : setting?.workspaceValue); + }; + if (!equal(previous, change.next)) { + // Each operation owns an immutable key. A delayed window cannot overwrite + // another window's backup of a more-specific user association. + const key = `${journalPrefix}${Date.now()}.${randomBytes(12).toString('hex')}`; + const journal: Journal = { created: performance.timeOrigin + performance.now(), state: change.beforeWrite }; + await storage.update(key, journal); + const discardUnappliedJournal = async () => { + if (equal(current(), previous) && equal(storage.get(key), journal)) { await storage.update(key, undefined); } + }; + if ((!cleanup && this.disposed) || !stillCurrent()) { await discardUnappliedJournal(); return; } + const fresh = current(); + if (!equal(fresh, previous)) { + // We have not changed preferences. Only the operation that actually + // installed this map owns its recovery; a coalesced stale plan must not + // supersede a newer user choice captured by that operation. + if (equal(storage.get(key), journal)) { await storage.update(key, undefined); } + if (!equal(fresh, change.next)) { + throw new Error('Editor associations changed while CSV was updating routing; retry the toggle.'); + } + return; // The actual writer also owns finalization of this map. + } + try { + if (!equal(fresh, change.next)) { + await this.api.workspace.getConfiguration('workbench').update('editorAssociations', change.value, target); + } + if (!equal(current(), change.next)) { + throw new Error('VS Code did not apply the routing preferences; a policy or read-only settings may prevent this change.'); + } + } catch (error) { + await discardUnappliedJournal(); + throw new Error(`CSV could not update ${scope} editor routing: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (!stillCurrent() || (!cleanup && this.disposed)) { return; } + const keep = Object.keys(change.finalState.entries).length || change.finalState.ignored.length; + const recovered = recovery(storage); + if (keep && !equal(recovered.state, change.finalState)) { + // A real observed user edit relinquishes ownership even if it needs no + // settings write. Choosing the old value later must remain user-owned. + const key = `${journalPrefix}${Date.now()}.${randomBytes(12).toString('hex')}`; + await storage.update(key, { created: performance.timeOrigin + performance.now(), state: change.finalState } as Journal); + } + if (!keep) { + const live = current(); + const relinquished = new Set([...recovered.state.ignored, ...Object.keys(recovered.state.released)]); + for (const { key, value } of recovered.journals) { + // Clear only the observed immutable record after its temporary values + // have gone. A concurrently added journal has a different key. + if (Object.entries(ownership(value.state).entries).some(([pattern, entry]) => !relinquished.has(pattern) && live[pattern] === entry.applied)) { continue; } + if (equal(storage.get(key), value)) { await storage.update(key, undefined); } + } + } + } +} diff --git a/src/editor-switch.ts b/src/editor-switch.ts new file mode 100644 index 0000000..3da7440 --- /dev/null +++ b/src/editor-switch.ts @@ -0,0 +1,222 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { CsvEditorProvider } from './CsvEditorProvider'; +import type { EditorSwitchRouting } from './editor-associations'; +import { getActiveUntitledCsvEditor, isUntitledCsv, openUntitledCsvEditor, showActiveUntitledCsvSource } from './untitled-editors'; + +const switchingTabs = new Set(); +const supportedExtensions = new Set(['.csv', '.tsv', '.tab', '.psv']); + +function getInputUri(input: unknown, viewType: string): vscode.Uri | undefined { + if (viewType === 'default') { + return input instanceof vscode.TabInputText ? input.uri : undefined; + } + return input instanceof vscode.TabInputCustom && input.viewType === viewType ? input.uri : undefined; +} + +export function supportsEditorReplacement(version: string, dirty: boolean): boolean { + const match = /^(\d+)\.(\d+)\./.exec(version); + if (!match) { + return false; + } + return Number(match[1]) > 1 || (Number(match[1]) === 1 && Number(match[2]) >= (dirty ? 120 : 100)); +} + +function showUpdateGuidance(dirty: boolean): void { + void vscode.window.showInformationMessage( + `CSV: Switching ${dirty ? 'an unsaved document' : 'between table and text'} requires VS Code ${dirty ? '1.120' : '1.100'} or newer. Update VS Code to use this action.` + ); +} + +function observeEditorReplacement(uri: vscode.Uri, source: string, target: string, viewColumn: vscode.ViewColumn) { + let resolve!: (complete: boolean) => void; + let subscription: vscode.Disposable | undefined; + let timer: ReturnType | undefined; + let settled = false; + const promise = new Promise(finish => { resolve = finish; }); + const finish = (complete: boolean) => { + if (settled) { return; } + settled = true; + subscription?.dispose(); + if (timer) { clearTimeout(timer); } + resolve(complete); + }; + const check = () => { + const group = vscode.window.tabGroups.all.find(candidate => candidate.viewColumn === viewColumn); + if (group?.tabs.some(tab => getInputUri(tab.input, target)?.toString() === uri.toString()) && + !group.tabs.some(tab => getInputUri(tab.input, source)?.toString() === uri.toString())) { finish(true); } + }; + // Native command completion can precede extension-host TabGroups events. + subscription = vscode.window.tabGroups.onDidChangeTabs(check); + return { promise, check, startTimeout: () => { + if (!settled) { timer = setTimeout(() => finish(false), 5000); } + }, cancel: () => finish(false) }; +} + +export async function toggleCsvEditor(prepareRouting?: (uri: vscode.Uri, target: 'default' | 'csv.editor') => Promise): Promise { + const untitled = getActiveUntitledCsvEditor(); + if (untitled) { + await showActiveUntitledCsvSource(); + return; + } + const group = vscode.window.tabGroups.activeTabGroup; + const activeInput = group.activeTab?.input; + const activeDocument = activeInput instanceof vscode.TabInputText + ? vscode.window.visibleTextEditors.find(editor => editor.viewColumn === group.viewColumn && + editor.document.uri.toString() === activeInput.uri.toString())?.document + : undefined; + if (activeDocument && isUntitledCsv(activeDocument)) { + await openUntitledCsvEditor(activeDocument, group.viewColumn); + return; + } + const tab = group.activeTab; + if (!tab) { + return; + } + const tableUri = getInputUri(tab.input, CsvEditorProvider.viewType); + const uri = tableUri ?? getInputUri(tab.input, 'default'); + if (!uri || uri.scheme === 'untitled' || !supportedExtensions.has(path.posix.extname(uri.path).toLowerCase())) { + return; + } + const source = tableUri ? CsvEditorProvider.viewType : 'default'; + const target = tableUri ? 'default' : CsvEditorProvider.viewType; + const switchKey = `${group.viewColumn}:${uri.toString()}`; + if (switchingTabs.has(switchKey)) { + return; + } + if (target === CsvEditorProvider.viewType && !vscode.workspace.getConfiguration('csv', uri).get('enabled', true)) { + void vscode.window.showInformationMessage('Enable the CSV extension to switch to the table view.'); + return; + } + if (!supportsEditorReplacement(vscode.version, false)) { + showUpdateGuidance(false); + return; + } + + switchingTabs.add(switchKey); + let routing: EditorSwitchRouting | undefined; + let replaced = false; + let replacement: ReturnType | undefined; + try { + if (!(await vscode.commands.getCommands(true)).includes('reopenActiveEditorWith')) { + showUpdateGuidance(false); + return; + } + if (tableUri) { + const controller = CsvEditorProvider.editors.find(candidate => + candidate.getDocumentUri().toString() === uri.toString() && candidate.getViewColumn() === group.viewColumn + ); + if (!controller || !(await controller.commitPendingDraft())) { + void vscode.window.showErrorMessage('CSV: Could not commit the active cell. The current view has been kept open.'); + return; + } + } + // The user may have changed tabs while the webview committed its draft. + const current = vscode.window.tabGroups.activeTabGroup; + if (current.viewColumn !== group.viewColumn || getInputUri(current.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + const dirty = current.activeTab!.isDirty || vscode.workspace.textDocuments.some(document => + document.uri.toString() === uri.toString() && document.isDirty + ); + if (!supportsEditorReplacement(vscode.version, dirty)) { + showUpdateGuidance(dirty); + return; + } + const sameFileTableElsewhere = target === CsvEditorProvider.viewType && vscode.window.tabGroups.all.some(other => + other.viewColumn !== group.viewColumn && other.tabs.some(otherTab => + getInputUri(otherTab.input, target)?.toString() === uri.toString() + ) + ); + if (sameFileTableElsewhere) { + void vscode.window.showInformationMessage('CSV: This file already has a table in another editor group. Switch from that group to keep both groups intact.'); + return; + } + if (current.activeTab!.isPreview) { + await vscode.commands.executeCommand('workbench.action.keepEditor'); + const pinned = vscode.window.tabGroups.activeTabGroup; + if (pinned.viewColumn !== group.viewColumn || getInputUri(pinned.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + } + routing = await prepareRouting?.(uri, target); + const afterRouting = vscode.window.tabGroups.activeTabGroup; + if (afterRouting.viewColumn !== group.viewColumn || getInputUri(afterRouting.activeTab?.input, source)?.toString() !== uri.toString()) { return; } + if (routing?.limitation) { void vscode.window.showInformationMessage(routing.limitation); } + // An unqualified open proves that the per-file association actually wins; + // explicit openWith would hide a longer user association taking precedence. + if (target === 'default' && routing?.supported) { + await vscode.commands.executeCommand('vscode.open', uri, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + } else { + await vscode.commands.executeCommand('vscode.openWith', uri, target, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + } + const targetGroup = vscode.window.tabGroups.activeTabGroup; + let targetReady = targetGroup.viewColumn === group.viewColumn && + getInputUri(targetGroup.activeTab?.input, target)?.toString() === uri.toString(); + if (targetReady && target === CsvEditorProvider.viewType) { + const controller = CsvEditorProvider.editors.find(candidate => + candidate.getDocumentUri().toString() === uri.toString() && candidate.getViewColumn() === group.viewColumn + ); + targetReady = !!controller && await controller.commitPendingDraft(); + } else if (targetReady) { + // Text focus can remain in a different split group after opening the target. + targetReady = vscode.window.visibleTextEditors.some(editor => + editor.viewColumn === group.viewColumn && editor.document.uri.toString() === uri.toString() + ); + } + const afterReady = vscode.window.tabGroups.activeTabGroup; + if (afterReady.viewColumn !== group.viewColumn || + (targetReady && getInputUri(afterReady.activeTab?.input, target)?.toString() !== uri.toString())) { + return; + } + if (!targetReady) { + if (target === 'default' && routing?.supported) { void vscode.window.showInformationMessage('CSV: Your editor associations still choose another view. They have been preserved; turn CSV off before using Find in Files.'); } + const original = vscode.window.tabGroups.all.find(candidate => candidate.viewColumn === group.viewColumn); + if (original?.tabs.some(candidate => getInputUri(candidate.input, source)?.toString() === uri.toString()) && + afterReady.viewColumn === group.viewColumn && + (!afterReady.activeTab || getInputUri(afterReady.activeTab.input, target)?.toString() === uri.toString())) { + await vscode.commands.executeCommand('vscode.openWith', uri, source, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + } + return; + } + // Reopening acts on selected tabs. Revealing this exact source through the + // ordinary open path narrows selection before replacing just this tab. + await vscode.commands.executeCommand('vscode.openWith', uri, source, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + const revealed = vscode.window.tabGroups.activeTabGroup; + if (revealed.viewColumn !== group.viewColumn || getInputUri(revealed.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + const dirtyAfterOpen = revealed.activeTab!.isDirty || vscode.workspace.textDocuments.some(document => + document.uri.toString() === uri.toString() && document.isDirty + ); + if (!supportsEditorReplacement(vscode.version, dirtyAfterOpen)) { + showUpdateGuidance(dirtyAfterOpen); + return; + } + // Native replacement opens the target before removing the source and retains + // the shared dirty text working copy. A separate tab close can prompt/discard. + replacement = observeEditorReplacement(uri, source, target, group.viewColumn); + await vscode.commands.executeCommand('reopenActiveEditorWith', target); + replacement.check(); + replacement.startTimeout(); + replaced = await replacement.promise; + if (replaced) { await routing?.commit(); } + } catch (error) { + console.error('CSV: could not switch editor', error); + void vscode.window.showErrorMessage('CSV: Could not switch between the table and text editor.'); + } finally { + replacement?.cancel(); + if (!replaced) { + try { await routing?.rollback(); } catch (error) { console.error('CSV: could not restore file routing after a cancelled switch', error); } + } + switchingTabs.delete(switchKey); + } +} diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..0012fd5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,91 +1,79 @@ import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; import { registerCsvCommands } from './commands'; +import { CsvEditorAssociationRouting } from './editor-associations'; +import { LargeFilePreviewProvider } from './large-file-preview'; +import { registerUntitledCsvEditors } from './untitled-editors'; -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { console.log('CSV: Extension activated'); - // Commands (toggle features, change separator/font) - registerCsvCommands(context); - + const routing = new CsvEditorAssociationRouting(context, vscode); + context.subscriptions.push(routing); + let lastRoutingError: string | undefined; + const synchronizeRouting = async () => { + try { + await routing.synchronize(); + lastRoutingError = undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message !== lastRoutingError) { void vscode.window.showErrorMessage(message); } + lastRoutingError = message; + throw error; + } + }; + let routingGeneration = 0; // Auto-refresh all open CSV editors when relevant CSV settings change - const cfgListener = vscode.workspace.onDidChangeConfiguration(e => { - // If the user turned the extension on, immediately upgrade open CSV/TSV tabs + const cfgListener = vscode.workspace.onDidChangeConfiguration(async e => { + const invokingGroup = vscode.window.tabGroups.activeTabGroup; + const invokingTab = invokingGroup?.activeTab; + const affectsRouting = e.affectsConfiguration('csv.enabled') || e.affectsConfiguration('workbench.editorAssociations'); + if (affectsRouting) { ++routingGeneration; } + if (affectsRouting) { + try { await synchronizeRouting(); } catch (error) { console.error('CSV: editor routing update failed', error); return; } + } + // Re-enable the invoking saved file; background tabs keep their current view. if (e.affectsConfiguration('csv.enabled')) { + const generation = routingGeneration; const enabled = vscode.workspace.getConfiguration('csv').get('enabled', true); console.log(`[CSV(enable)]: configuration changed -> enabled=${enabled}`); if (enabled) { - // Snapshot candidates first to avoid mutating collections during iteration - type Candidate = { group: vscode.TabGroup; groupIndex: number; tab: vscode.Tab; tabIndex: number; uri: vscode.Uri; wasActive: boolean; wasPreview: boolean; viewColumn: vscode.ViewColumn | undefined }; - const candidates: Candidate[] = []; - const groups = vscode.window.tabGroups.all; - console.log(`[CSV(enable)]: scanning ${groups.length} tab group(s)`); - groups.forEach((group, gi) => { - console.log(`[CSV(enable)]: group[${gi}] has ${group.tabs.length} tab(s)`); - group.tabs.forEach((tab, ti) => { - const input: any = (tab as any).input; - const viewType = input?.viewType; - const uri: vscode.Uri | undefined = input?.uri instanceof vscode.Uri ? (input.uri as vscode.Uri) : undefined; - const path = uri?.fsPath || '(no-uri)'; - console.log(`[CSV(enable)]: group[${gi}] tab[${ti}] vt=${viewType ?? '(text?)'} uri=${path} active=${tab.isActive} preview=${tab.isPreview}`); - if (!input) return; - if (viewType === CsvEditorProvider.viewType) return; // already our editor - if (!uri) return; - const fsPath = uri.fsPath?.toLowerCase?.() || ''; - const isCsvLike = fsPath.endsWith('.csv') || fsPath.endsWith('.tsv') || fsPath.endsWith('.tab') || fsPath.endsWith('.psv'); - console.log(`[CSV(enable)]: -> eligible=${isCsvLike}`); - if (!isCsvLike) return; - candidates.push({ group, groupIndex: gi, tab, tabIndex: ti, uri, wasActive: tab.isActive, wasPreview: tab.isPreview, viewColumn: group.viewColumn }); - }); - }); - - console.log(`[CSV(enable)]: candidates=${candidates.length}`); - (async () => { - const processed = new Set(); - for (const c of candidates) { - try { - console.log(`[CSV(enable)]: closing group[${c.groupIndex}] tab[${c.tabIndex}] uri=${c.uri.fsPath}`); - await vscode.window.tabGroups.close(c.tab); - } catch (err) { - console.error(`[CSV(enable)]: close failed for ${c.uri.fsPath}`, err); - } - try { - const openOpts: any = { viewColumn: c.viewColumn, preserveFocus: !c.wasActive, preview: c.wasPreview }; - console.log(`[CSV(enable)]: opening custom editor for ${c.uri.fsPath} in column=${c.viewColumn}`); - await vscode.commands.executeCommand('vscode.openWith', c.uri, CsvEditorProvider.viewType, openOpts); - processed.add(c.uri.toString()); - } catch (err) { - console.error(`[CSV(enable)]: openWith failed for ${c.uri.fsPath}`, err); - } - } - - // Final sweep: ensure no stale non-custom tabs remain for processed URIs across all groups + const input: any = invokingTab?.input; + const uri: vscode.Uri | undefined = input?.uri instanceof vscode.Uri ? input.uri : undefined; + const eligible = !input?.viewType && uri?.scheme !== 'untitled' && /\.(csv|tsv|tab|psv)$/i.test(uri?.path || uri?.fsPath || ''); + const stillEnabled = () => generation === routingGeneration && vscode.workspace.getConfiguration('csv').get('enabled', true); + // An inactive tab cannot be opened in-place without activating it inside + // its group. Upgrade only the owner that invoked this mode change. + if (eligible && uri && invokingTab && invokingGroup && stillEnabled() && + vscode.window.tabGroups.activeTabGroup === invokingGroup && invokingGroup.activeTab === invokingTab) { try { - const stale: vscode.Tab[] = []; - vscode.window.tabGroups.all.forEach((g, gi) => { - g.tabs.forEach((t, ti) => { - const inp: any = (t as any).input; - const vt = inp?.viewType; - const u: vscode.Uri | undefined = inp?.uri instanceof vscode.Uri ? (inp.uri as vscode.Uri) : undefined; - const key = u?.toString(); - if (key && processed.has(key) && vt !== CsvEditorProvider.viewType) { - console.log(`[CSV(enable)]: final-sweep closing group[${gi}] tab[${ti}] uri=${u?.fsPath} vt=${vt}`); - stale.push(t); - } - }); + const hadTable = invokingGroup.tabs.some(tab => { + const candidate: any = tab.input; + return candidate?.viewType === CsvEditorProvider.viewType && candidate?.uri?.toString() === uri.toString(); + }); + await vscode.commands.executeCommand('vscode.open', uri, { + viewColumn: invokingGroup.viewColumn, preserveFocus: false, preview: invokingTab.isPreview }); - if (stale.length) { - await vscode.window.tabGroups.close(stale); + if (stillEnabled() && vscode.window.tabGroups.activeTabGroup === invokingGroup) { + const active: any = invokingGroup.activeTab?.input; + const choseTable = active?.viewType === CsvEditorProvider.viewType && active?.uri?.toString() === uri.toString(); + if (!hadTable && choseTable && invokingGroup.tabs.includes(invokingTab) && !invokingTab.isDirty) { + await vscode.window.tabGroups.close(invokingTab, true); + } } - } catch (err) { - console.error('[CSV(enable)]: final-sweep error', err); - } - - console.log(`[CSV(enable)]: done processing ${candidates.length} candidate(s)`); - })(); + } catch (error) { console.error('[CSV(enable)]: editor upgrade failed', error); } + } + } else { + CsvEditorProvider.editors.slice().forEach(ed => ed.refresh()); } } + if (e.affectsConfiguration('csv.defaultIgnoreRows')) { + CsvEditorProvider.editors + .filter(editor => !CsvEditorProvider.hasHiddenRowsOverride(context, editor.getDocumentUri()) && e.affectsConfiguration('csv.defaultIgnoreRows', editor.getDocumentUri())) + .forEach(editor => editor.refreshViewSafely()); + } + const keys = [ 'csv.fontFamily', 'csv.cellPadding', @@ -105,14 +93,21 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(cfgListener); + // Listen before the first await so settings changes during recovery are queued. + // Persisted Off routing must be installed before commands/provider handoffs. + try { await synchronizeRouting(); } catch (error) { console.error('CSV: editor routing initialization failed', error); } + registerCsvCommands(context, synchronizeRouting, (uri, target) => routing.prepareEditorSwitch(uri, target)); + // Register the custom editor provider for CSV/TSV context.subscriptions.push( + vscode.window.registerCustomEditorProvider(LargeFilePreviewProvider.viewType, new LargeFilePreviewProvider(context)), vscode.window.registerCustomEditorProvider( CsvEditorProvider.viewType, new CsvEditorProvider(context), { supportsMultipleEditorsPerDocument: false } ) ); + registerUntitledCsvEditors(context); } export function deactivate() { diff --git a/src/filter-values.ts b/src/filter-values.ts new file mode 100644 index 0000000..b9321af --- /dev/null +++ b/src/filter-values.ts @@ -0,0 +1,288 @@ +export type FilterDataType = 'string' | 'number' | 'date' | 'boolean'; + +const decimalNumber = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i; +const groupedNumber = /^[+-]?\d{1,3}([, '\u00a0\u202f])\d{3}(?:\1\d{3})*(?:\.\d*)?(?:e[+-]?\d+)?$/i; + +function numericText(value: string): string | undefined { + const text = value.trim(); + return decimalNumber.test(text) || groupedNumber.test(text) ? text.replace(/[, '\u00a0\u202f]/g, '') : undefined; +} + +/** Parse a complete finite decimal, optionally using consistent thousands groups. */ +export function parseFilterNumber(value: string): number | undefined { + const text = numericText(value); + if (text === undefined) { + return undefined; + } + const number = Number(text); + return Number.isFinite(number) ? number : undefined; +} + +type NumberComparison = -1 | 0 | 1; +interface ComparableNumber { + sign: NumberComparison; + digits: string; + exponent: number; + shift: number; +} + +function comparableNumber(value: string): ComparableNumber | undefined { + const text = numericText(value); + if (text === undefined || !Number.isFinite(Number(text))) { + return undefined; + } + const [mantissa, exponentText] = text.toLowerCase().split('e'); + const [integer, fraction = ''] = mantissa.replace(/^[+-]/, '').split('.'); + const digits = (integer + fraction).replace(/^0+/, ''); + if (!digits) { + return { sign: 0, digits: '', exponent: 0, shift: 0 }; + } + const exponent = Number(exponentText || '0'); + if (!Number.isSafeInteger(exponent)) { + return undefined; + } + return { sign: text.startsWith('-') ? -1 : 1, digits, exponent, shift: digits.length - fraction.length }; +} + +function compareNumbers(a: ComparableNumber, b: ComparableNumber): NumberComparison { + if (a.sign !== b.sign) { + return a.sign < b.sign ? -1 : 1; + } + if (a.sign === 0) { + return 0; + } + const exponentDifference = a.exponent - b.exponent; + const shiftDifference = a.shift - b.shift; + // Do not add an input-length shift to an exponent at the safe-integer boundary. + // Close exponents subtract exactly; distant exponents already determine order. + const orderDifference = exponentDifference > Math.abs(shiftDifference) ? 1 + : exponentDifference < -Math.abs(shiftDifference) ? -1 + : exponentDifference + shiftDifference; + if (orderDifference !== 0) { + return (orderDifference < 0 ? -a.sign : a.sign) as NumberComparison; + } + for (let index = 0; index < Math.max(a.digits.length, b.digits.length); index++) { + const first = index < a.digits.length ? a.digits.charCodeAt(index) : 48; + const second = index < b.digits.length ? b.digits.charCodeAt(index) : 48; + if (first !== second) { + return (first < second ? -a.sign : a.sign) as NumberComparison; + } + } + return 0; +} + +/** Normalize the threshold once while retaining exact decimal comparison. */ +export function compileFilterNumberComparison(query: string): ((cell: string) => NumberComparison | undefined) | undefined { + const threshold = comparableNumber(query); + if (!threshold) { + return undefined; + } + return cell => { + const value = comparableNumber(cell); + return value ? compareNumbers(value, threshold) : undefined; + }; +} + +export function compareFilterNumbers(a: string, b: string): NumberComparison | undefined { + return compileFilterNumberComparison(b)?.(a); +} + +const trueValues = new Set(['true', 't', 'yes', 'y', 'on', '1']); +const falseValues = new Set(['false', 'f', 'no', 'n', 'off', '0']); + +export function parseFilterBoolean(value: string): boolean | undefined { + const text = value.trim().toLowerCase(); + if (trueValues.has(text)) { + return true; + } + if (falseValues.has(text)) { + return false; + } + return undefined; +} + +interface DateParts { + year: number; + month: number; + day: number; + tail: string; + ordinal?: string; +} + +interface TimeParts { + hour: number; + minute: number; + second: number; + millisecond: number; + remainder: string; + offset: number; +} + +const monthNames = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; +const weekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +function monthNumber(value: string): number { + const name = value.toLowerCase().replace(/\.$/, ''); + return monthNames.findIndex(month => month === name || month.slice(0, 3) === name || (month === 'september' && name === 'sept')) + 1; +} + +function parseDateParts(value: string): DateParts | undefined { + let match = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})(.*)$/.exec(value); + if (match) { + return { year: Number(match[1]), month: Number(match[3]), day: Number(match[4]), tail: match[5] }; + } + match = /^(\d{1,2})([-/.])(\d{1,2})\2(\d{4})(.*)$/.exec(value); + if (match) { + const first = Number(match[1]), second = Number(match[3]); + // Prefer month/day when both are possible; a leading value above 12 is a day. + const dayFirst = first > 12; + return { year: Number(match[4]), month: dayFirst ? second : first, day: dayFirst ? first : second, tail: match[5] }; + } + match = /^([a-z]{3,9}\.?)(?:\s+|-)(\d{1,2})(st|nd|rd|th)?(?:,\s*|\s+|-)(\d{4})(.*)$/i.exec(value); + if (match) { + return { year: Number(match[4]), month: monthNumber(match[1]), day: Number(match[2]), ordinal: match[3], tail: match[5] }; + } + match = /^(\d{1,2})(st|nd|rd|th)?(?:\s+|-)([a-z]{3,9}\.?)(?:,\s*|\s+|-)(\d{4})(.*)$/i.exec(value); + if (match) { + return { year: Number(match[4]), month: monthNumber(match[3]), day: Number(match[1]), ordinal: match[2], tail: match[5] }; + } + return undefined; +} + +function parseOffset(value: string | undefined): number | undefined { + if (!value || /^(?:z|utc|gmt)$/i.test(value)) { + return 0; + } + const match = /^(?:utc|gmt)?([+-])(\d{2})(?::?(\d{2}))?$/i.exec(value); + if (!match) { + return undefined; + } + const hour = Number(match[2]), minute = Number(match[3] || '0'); + if (hour > 23 || minute > 59) { + return undefined; + } + return (match[1] === '-' ? -1 : 1) * (hour * 60 + minute); +} + +function parseTimeParts(tail: string): TimeParts | undefined { + const midnight = { hour: 0, minute: 0, second: 0, millisecond: 0, remainder: '', offset: 0 }; + if (!tail) { + return midnight; + } + if (!/^(?:[tT]|\s)/.test(tail)) { + return undefined; + } + const hasTimeSeparator = /^[tT]/.test(tail); + const text = (hasTimeSeparator ? tail.slice(1) : tail).trim(); + if (!text) { + return undefined; + } + if (!hasTimeSeparator && /^(?:z|utc|gmt)$/i.test(text)) { + return midnight; + } + const match = /^(\d{1,2})(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?[ \t]*(am|pm)?[ \t]*(\S+)?$/i.exec(text); + if (!match || (match[2] === undefined && match[5] === undefined)) { + return undefined; + } + let hour = Number(match[1]); + const minute = Number(match[2] || '0'), second = Number(match[3] || '0'); + const meridiem = match[5]?.toLowerCase(); + if (minute > 59 || second > 59 || (meridiem ? hour < 1 || hour > 12 : hour > 23)) { + return undefined; + } + if (meridiem) { + hour = hour % 12 + (meridiem === 'pm' ? 12 : 0); + } + const offset = parseOffset(match[6]); + if (offset === undefined) { + return undefined; + } + const fraction = match[4] || ''; + const millisecond = Number(fraction.slice(0, 3).padEnd(3, '0')); + const remainder = fraction.slice(3).replace(/0+$/, ''); + return { hour, minute, second, millisecond, remainder, offset }; +} + +function validCalendarDate({ year, month, day, ordinal }: DateParts): boolean { + if (month < 1 || month > 12 || day < 1) { + return false; + } + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (day > days[month - 1]) { + return false; + } + if (ordinal) { + const suffix = day % 100 >= 11 && day % 100 <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' } as Record)[day % 10] || 'th'; + if (suffix !== ordinal.toLowerCase()) { + return false; + } + } + return true; +} + +interface ComparableDate { + millisecond: number; + remainder: string; +} + +function comparableDate(value: string): ComparableDate | undefined { + let text = value.trim(); + const prefix = /^(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?),?\s+/i.exec(text); + const weekday = prefix ? weekdays.indexOf(prefix[1].slice(0, 3).toLowerCase()) : undefined; + if (prefix) { + text = text.slice(prefix[0].length); + } + const date = parseDateParts(text); + if (!date || !validCalendarDate(date)) { + return undefined; + } + const time = parseTimeParts(date.tail); + if (!time) { + return undefined; + } + // setUTCFullYear avoids Date.UTC's special interpretation of years 00 through 99. + const result = new Date(0); + result.setUTCFullYear(date.year, date.month - 1, date.day); + if (weekday !== undefined && result.getUTCDay() !== weekday) { + return undefined; + } + result.setUTCHours(time.hour, time.minute, time.second, time.millisecond); + return { millisecond: result.getTime() - time.offset * 60000, remainder: time.remainder }; +} + +/** Parse explicit complete calendar formats; date-only and unzoned times use UTC. */ +export function parseFilterDate(value: string): number | undefined { + return comparableDate(value)?.millisecond; +} + +/** Compare accepted timestamps without discarding microsecond/nanosecond digits. */ +export function compileFilterDateComparison(query: string): ((cell: string) => NumberComparison | undefined) | undefined { + const threshold = comparableDate(query); + if (!threshold) { + return undefined; + } + return cell => { + const value = comparableDate(cell); + if (!value) { + return undefined; + } + if (value.millisecond !== threshold.millisecond) { + return value.millisecond < threshold.millisecond ? -1 : 1; + } + // Remainders are nonnegative even before the Unix epoch. Compare their + // decimal digits with implicit right zeros, using only the input's length. + for (let index = 0; index < Math.max(value.remainder.length, threshold.remainder.length); index++) { + const first = index < value.remainder.length ? value.remainder.charCodeAt(index) : 48; + const second = index < threshold.remainder.length ? threshold.remainder.charCodeAt(index) : 48; + if (first !== second) { + return first < second ? -1 : 1; + } + } + return 0; + }; +} + +export function compareFilterDates(a: string, b: string): NumberComparison | undefined { + return compileFilterDateComparison(b)?.(a); +} diff --git a/src/large-file-preview.ts b/src/large-file-preview.ts new file mode 100644 index 0000000..8d63c12 --- /dev/null +++ b/src/large-file-preview.ts @@ -0,0 +1,170 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +import { CsvEditorProvider } from './CsvEditorProvider'; +import { CsvPreviewPage, PreviewHistory, readCsvPreviewPage } from './bounded-csv-reader'; + +const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +}[char] as string)); + +export function renderPreviewRows(rows: string[][], firstRecord: number): string { + if (rows.length === 0) { + return '

No records in this file.

'; + } + const width = Math.max(...rows.map(row => row.length)); + const headings = Array.from({ length: width }, (_, index) => `
`).join(''); + const body = rows.map((row, index) => `${row.map(value => ``).join('')}`).join(''); + return `
${safe}${safe}
${r + 1}${rowIds[r] - bodyStart + 1}${safe}${safe}
${initialBodyRows.length + 1}${virtualAbs - bodyStart + 1}
${r + 1}${rowIds[r] - bodyStart + 1}${safe}${safe}
${displayIdx}${displayIdx}
${displayIdx}${displayIdx}
Column ${index + 1}
${firstRecord + index + 1}${escapeHtml(value)}
${headings}${body}
Record
`; +} + +class PreviewDocument implements vscode.CustomDocument { + disposed = false; + constructor(readonly uri: vscode.Uri) {} + dispose(): void { this.disposed = true; } +} + +export class LargeFilePreviewProvider implements vscode.CustomReadonlyEditorProvider { + static readonly viewType = 'csv.largeFilePreview'; + constructor(private readonly context: vscode.ExtensionContext) {} + + async openCustomDocument(uri: vscode.Uri): Promise { + if (uri.scheme !== 'file') { + throw new Error('Large File Preview currently supports host-local file URIs only. Use the normal CSV editor for this location.'); + } + return new PreviewDocument(uri); + } + + async resolveCustomEditor(document: PreviewDocument, panel: vscode.WebviewPanel, token: vscode.CancellationToken): Promise { + panel.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'media'))] + }; + panel.webview.html = this.html(panel.webview); + const history = new PreviewHistory(); + let page: CsvPreviewPage | undefined; + let fileVersion: string | undefined; + let selectedDelimiter: string | undefined; + let lastError = ''; + let disposed = false; + let pending = Promise.resolve(); + const cancelled = () => disposed || document.disposed || token.isCancellationRequested; + const send = (error?: string) => { + if (cancelled()) { + return; + } + const first = history.current.record; + void panel.webview.postMessage({ + type: 'page', + html: page ? renderPreviewRows(page.rows, first) : '', + status: page?.rows.length ? `Saved file · Records ${first + 1}–${first + page.rows.length} · Read-only` : 'Saved file · Read-only', + canBack: history.canGoBack, + canNext: !!page && !page.done && !error, + error: error ?? '' + }); + }; + const read = async (reset: boolean) => { + const config = vscode.workspace.getConfiguration('csv', document.uri); + if (!config.get('enabled', true)) { + throw new Error('CSV is disabled. Enable it before opening Large File Preview.'); + } + const settings = CsvEditorProvider.getSeparatorSettings(document.uri); + const override = CsvEditorProvider.getSeparatorForUri(this.context, document.uri); + if (!override && settings.mode === 'auto') { + throw new Error('Large File Preview does not support automatic separator detection. Set a per-file separator override in the normal editor, or set csv.separatorMode to extension/default, then choose Back to Start.'); + } + const delimiter = override ?? CsvEditorProvider.resolveInheritedSeparator(document.uri.fsPath, '', settings); + if (!reset && selectedDelimiter !== delimiter) { + throw new Error('The separator changed. Choose Back to Start to reload the preview.'); + } + selectedDelimiter = delimiter; + page = await readCsvPreviewPage(document.uri.fsPath, { + delimiter, + offset: history.current.offset, + maxRows: config.get('largeFilePreviewRows', 1000), + expectedVersion: reset ? undefined : fileVersion, + cancelled + }); + fileVersion = page.version; + lastError = ''; + send(); + }; + panel.webview.onDidReceiveMessage(message => { + pending = pending.then(async () => { + if (cancelled()) { + return; + } + if (message.type === 'ready') { + if (page) { + send(); + } else if (lastError) { + send(lastError); + } else { + await read(true); + } + } else if (message.type === 'start') { + history.reset(); + page = undefined; + fileVersion = undefined; + lastError = ''; + await read(true); + } else if (message.type === 'next' && page && !page.done) { + history.next({ offset: page.nextOffset, record: history.current.record + page.rows.length }); + await read(false); + } else if (message.type === 'previous' && history.canGoBack) { + history.previous(); + await read(false); + } + }).catch(error => { + // Discard stale rows if navigation or source validation fails. The error + // remains visible; Back to Start can always retry a changed file. + page = undefined; + lastError = error instanceof Error ? error.message : 'Unable to read this preview page.'; + send(lastError); + }); + return pending; + }); + panel.onDidDispose(() => { disposed = true; }); + } + + private html(webview: vscode.Webview): string { + const nonce = randomBytes(16).toString('hex'); + const script = webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'large-preview.js'))); + return ` + + + + +

Large File Preview · UTF-8 · Read-only. Each page replaces the previous page. The first record is shown as data.

+
+`; + } +} + +export async function openLargeFilePreview(uri?: vscode.Uri): Promise { + if (!vscode.workspace.getConfiguration('csv').get('enabled', true)) { + void vscode.window.showInformationMessage('Enable CSV before opening Large File Preview.'); + return; + } + const target = uri instanceof vscode.Uri ? uri : (await vscode.window.showOpenDialog({ + canSelectMany: false, + openLabel: 'Open Read-only Preview', + filters: { 'CSV/TSV/PSV': ['csv', 'tsv', 'tab', 'psv'] } + }))?.[0]; + if (!target) { + return; + } + if (target.scheme !== 'file') { + void vscode.window.showErrorMessage('Large File Preview currently supports host-local files only. Use the normal CSV editor for this location.'); + return; + } + await vscode.commands.executeCommand('vscode.openWith', target, LargeFilePreviewProvider.viewType, { preview: false }); +} diff --git a/src/table-comparison.ts b/src/table-comparison.ts new file mode 100644 index 0000000..b92b815 --- /dev/null +++ b/src/table-comparison.ts @@ -0,0 +1,204 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +import { CsvEditorProvider } from './CsvEditorProvider'; +import { compareCsvRows, comparisonLimits, comparisonPages, CsvTableDiff, parseComparisonRows } from './csv-table-diff'; + +const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char] as string)); + +export function renderComparisonSide(diff: CsvTableDiff, side: 'original' | 'modified', start: number, end: number): string { + const columns = Math.max(1, ...diff.originalRows.map(row => row.length), ...diff.modifiedRows.map(row => row.length)); + const headings = Array.from({ length: columns }, (_, col) => `${col + 1}`).join(''); + const rows = diff.records.slice(start, end).map((record, index) => { + const source = record[side]; + const values = source === undefined ? undefined : (side === 'original' ? diff.originalRows : diff.modifiedRows)[source]; + const otherIndex = record[side === 'original' ? 'modified' : 'original']; + const other = otherIndex === undefined ? undefined : (side === 'original' ? diff.modifiedRows : diff.originalRows)[otherIndex]; + const rowLabel = record.kind === 'added' ? 'Added' : record.kind === 'removed' ? 'Removed' : record.kind === 'changed' ? 'Changed' : 'Unchanged'; + const cells = values === undefined ? `No record` + : Array.from({ length: Math.max(values.length, other?.length ?? 0) }, (_, col) => { + if (col >= values.length) { return '—'; } + const changed = record.changedColumns.includes(col); + const kind = changed ? (side === 'original' ? 'removed' : 'added') : ''; + return `${escapeHtml(values[col])}`; + }).join(''); + return `${source === undefined ? '—' : source + 1}${cells}`; + }).join(''); + return `${''.repeat(columns)}${headings}${rows}
Record
`; +} + +export class TableComparison { + private diff: CsvTableDiff | undefined; + private pages = [{ start: 0, end: 0 }]; + private currentPage = 0; + private focusedRecord: number | undefined; + private error = ''; + private disposed = false; + private timer: ReturnType | undefined; + private readonly changeSubscription: vscode.Disposable; + + constructor( + private readonly context: vscode.ExtensionContext, + private original: vscode.TextDocument, + private modified: vscode.TextDocument, + private readonly panel: vscode.WebviewPanel + ) { + panel.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))] }; + panel.webview.html = this.html(); + panel.webview.onDidReceiveMessage(message => { + if (this.disposed) { return; } + if (message.type === 'ready') { this.showPage(); } + else if (message.type === 'page' && Number.isInteger(message.index)) { + this.currentPage = Math.max(0, Math.min(this.pages.length - 1, message.index)); + this.focusedRecord = undefined; + this.showPage(); + } else if (message.type === 'change' && (message.direction === 1 || message.direction === -1)) { + const changes = this.diff?.records.map((record, index) => record.kind === 'equal' ? -1 : index).filter(index => index >= 0) ?? []; + const boundary = this.focusedRecord ?? (this.pages[this.currentPage].start - (message.direction === 1 ? 1 : 0)); + const target = message.direction === 1 ? changes.find(index => index > boundary) : changes.reverse().find(index => index < boundary); + if (target !== undefined) { + this.focusedRecord = target; + this.currentPage = this.pages.findIndex(page => target >= page.start && target < page.end); + } + this.showPage(); + } + }); + this.changeSubscription = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.toString() !== original.uri.toString() && event.document.uri.toString() !== modified.uri.toString()) { return; } + if (event.document.uri.toString() === original.uri.toString()) { this.original = event.document; } + if (event.document.uri.toString() === modified.uri.toString()) { this.modified = event.document; } + if (this.timer) { clearTimeout(this.timer); } + this.timer = setTimeout(() => this.refresh(), 150); + }); + panel.onDidDispose(() => this.dispose()); + this.refresh(); + } + + dispose(): void { + this.disposed = true; + if (this.timer) { clearTimeout(this.timer); } + this.changeSubscription.dispose(); + } + + refresh(): void { + if (this.disposed) { return; } + try { + const rows = (document: vscode.TextDocument) => { + const text = document.getText(); + const config = vscode.workspace.getConfiguration('csv', document.uri); + if (!config.get('enabled', true)) { throw new Error('CSV is disabled.'); } + const limit = config.get('maxFileSizeMB', 10); + if (Number.isFinite(limit) && limit > 0 && Buffer.byteLength(text, 'utf8') > limit * 1024 * 1024) { + throw new Error('An input exceeds csv.maxFileSizeMB. Use a text diff or adjust the existing file-size limit.'); + } + const separator = CsvEditorProvider.getSeparatorForUri(this.context, document.uri) + ?? CsvEditorProvider.resolveInheritedSeparator(document.uri.fsPath || document.uri.path, text, CsvEditorProvider.getSeparatorSettings(document.uri)); + return parseComparisonRows(text, separator); + }; + this.diff = compareCsvRows(rows(this.original), rows(this.modified)); + this.pages = comparisonPages(this.diff); + this.currentPage = Math.min(this.currentPage, this.pages.length - 1); + this.focusedRecord = undefined; + this.error = ''; + } catch (error) { + this.diff = undefined; + this.pages = [{ start: 0, end: 0 }]; + this.currentPage = 0; + this.error = error instanceof Error ? error.message : 'Unable to compare these files.'; + } + this.showPage(); + } + + private showPage(): void { + if (this.disposed) { return; } + const { start, end } = this.pages[this.currentPage]; + const changes = this.diff?.records.map((record, index) => record.kind === 'equal' ? -1 : index).filter(index => index >= 0) ?? []; + const boundary = this.focusedRecord ?? start; + void this.panel.webview.postMessage({ + type: 'comparison', + originalHtml: this.diff ? renderComparisonSide(this.diff, 'original', start, end) : '', + modifiedHtml: this.diff ? renderComparisonSide(this.diff, 'modified', start, end) : '', + page: this.currentPage, + pageCount: this.pages.length, + canPreviousChange: changes.some(index => index < boundary), + canNextChange: changes.some(index => index > (this.focusedRecord ?? start - 1)), + focusRecord: this.focusedRecord, + status: this.diff ? `Page ${this.currentPage + 1}/${this.pages.length} · Added ${this.diff.added} · Removed ${this.diff.removed} · Changed ${this.diff.changed} records` : '', + notice: this.diff?.approximate ? 'Some large changed sections are aligned by position after the alignment work limit. Cell differences are exact for the displayed pairs.' : '', + error: this.error + }); + } + + private html(): string { + const nonce = randomBytes(16).toString('hex'); + const script = this.panel.webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'table-comparison.js'))); + return ` + + + +

Read-only cell comparison · Red: original differences · Green: modified differences · Scrolling stays synchronized. Quoting and line-ending style are not compared.

+
+

Original: ${escapeHtml(path.basename(this.original.uri.path))}

+

Modified: ${escapeHtml(path.basename(this.modified.uri.path))}

+`; + } +} + +export async function compareFilesAsTable(context: vscode.ExtensionContext, resource?: vscode.Uri, selected?: vscode.Uri[]): Promise { + if (!vscode.workspace.getConfiguration('csv').get('enabled', true)) { + void vscode.window.showInformationMessage('Enable CSV before comparing files as a table.'); return; + } + let original: vscode.Uri | undefined; + let modified: vscode.Uri | undefined; + if (Array.isArray(selected) && selected.length === 2 && selected.every(uri => uri instanceof vscode.Uri)) { + [original, modified] = selected; + } else { + const active = CsvEditorProvider.getActiveProvider(); + original = resource instanceof vscode.Uri ? resource : vscode.window.activeTextEditor?.document.uri ?? (active?.isActive() ? active.getDocumentUri() : undefined); + if (!original) { + original = (await vscode.window.showOpenDialog({ canSelectMany: false, openLabel: 'Select Original CSV File' }))?.[0]; + } + if (!original) { return; } + modified = (await vscode.window.showOpenDialog({ canSelectMany: false, openLabel: 'Select Modified CSV File' }))?.[0]; + } + if (!original || !modified) { return; } + try { + const documents: vscode.TextDocument[] = []; + for (const uri of [original, modified]) { + const limit = vscode.workspace.getConfiguration('csv', uri).get('maxFileSizeMB', 10); + const existing = vscode.workspace.textDocuments.find(document => !document.isClosed && document.uri.toString() === uri.toString()); + let size: number | undefined; + try { size = (await vscode.workspace.fs.stat(uri)).size; } catch { /* Existing documents can still be compared within decoded-size limits. */ } + if (size === undefined && !existing) { + throw new Error('Cannot verify this source size. Open it in a normal editor first, then compare the already-open document.'); + } + if (size !== undefined && size > comparisonLimits.sourceBytes) { + throw new Error('Table comparison supports at most 10 MiB of saved bytes per input, regardless of csv.maxFileSizeMB. Use a text diff for larger files.'); + } + if (size !== undefined && Number.isFinite(limit) && limit > 0 && size > limit * 1024 * 1024) { + throw new Error('An input exceeds csv.maxFileSizeMB. Use a text diff or adjust the existing file-size limit.'); + } + const document = existing ?? await vscode.workspace.openTextDocument(uri); + if (Buffer.byteLength(document.getText(), 'utf8') > comparisonLimits.textBytes) { + throw new Error('Table comparison supports at most 5 MiB of decoded text per input. Use a text diff for larger files.'); + } + documents.push(document); + } + const [before, after] = documents; + const panel = vscode.window.createWebviewPanel('csv.tableComparison', `CSV Compare: ${path.basename(original.path)} ↔ ${path.basename(modified.path)}`, vscode.ViewColumn.Active, {}); + new TableComparison(context, before, after, panel); + } catch (error) { + void vscode.window.showErrorMessage(error instanceof Error ? error.message : 'Unable to open the CSV comparison.'); + } +} diff --git a/src/test-support/sizing-webview-harness.ts b/src/test-support/sizing-webview-harness.ts new file mode 100644 index 0000000..59ff053 --- /dev/null +++ b/src/test-support/sizing-webview-harness.ts @@ -0,0 +1,174 @@ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; + +type Listener = { callback: (event: any) => void; capture: boolean }; + +// Execute the complete shipped script. The small DOM model supplies selection, +// focus and event propagation; it does not reimplement clipboard behavior. +export function createWebview(options: { state?: any; persistedColumns?: Record; header?: boolean } = {}) { + class Element { + listeners = new Map(); + attributes = new Map(); + classes = new Set(); + children: Element[] = []; + parentElement: Element | null = null; + style: Record = {}; + dataset: Record = {}; + textContent = ''; + value = ''; + isContentEditable = false; + isConnected = true; + rect = { left: 0, right: 100, top: 0, bottom: 30, width: 100, height: 30 }; + getBoundingClientRect() { return this.rect; } + get previousElementSibling(): Element | null { return this.parentElement?.children[this.parentElement.children.indexOf(this) - 1] ?? null; } + get nextElementSibling(): Element | null { return this.parentElement?.children[this.parentElement.children.indexOf(this) + 1] ?? null; } + classList = { + add: (...names: string[]) => names.forEach(name => this.classes.add(name)), + remove: (...names: string[]) => names.forEach(name => this.classes.delete(name)), + contains: (name: string) => this.classes.has(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !this.classes.has(name); + if (next) { this.classes.add(name); } else { this.classes.delete(name); } + return next; + } + }; + constructor(public tagName = 'DIV', public id = '') {} + addEventListener(type: string, callback: (event: any) => void, options?: any) { + const listeners = this.listeners.get(type) || []; + listeners.push({ callback, capture: options === true }); + this.listeners.set(type, listeners); + } + removeEventListener() {} + setAttribute(name: string, value: string) { this.attributes.set(name, value); } + getAttribute(name: string) { return this.attributes.get(name) ?? null; } + get tBodies() { return this.children.filter(child => child.tagName === 'TBODY'); } + hasAttribute(name: string) { return this.attributes.has(name); } + appendChild(child: Element) { child.parentElement = this; this.children.push(child); } + contains(other: Element): boolean { return other === this || this.children.some(child => child.contains(other)); } + focus() { document.activeElement = this; } + scrollIntoView() {} + matches(selector: string): boolean { + if (selector.endsWith(':last-child')) { + return this === this.parentElement?.children[this.parentElement.children.length - 1] && this.matches(selector.slice(0, -':last-child'.length)); + } + if (selector.includes(' ')) { + const [ancestor, child] = selector.split(' '); + return this.matches(child) && !!this.parentElement?.closest(ancestor); + } + if (selector.startsWith('#')) { return this.id === selector.slice(1); } + const tag = selector.match(/^[a-z]+/i)?.[0]; + if (tag && this.tagName !== tag.toUpperCase()) { return false; } + if (selector.includes('.')) { return selector.split('.').slice(1).every(name => this.classes.has(name)); } + for (const match of selector.matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)) { + if (match[2] === undefined ? !this.hasAttribute(match[1]) : this.getAttribute(match[1]) !== match[2]) { return false; } + } + return true; + } + closest(selector: string): Element | null { + if (selector.split(',').some(part => this.matches(part.trim()))) { return this; } + return this.parentElement?.closest(selector) ?? null; + } + querySelectorAll(selector: string): Element[] { + const descendants = this.children.flatMap(child => [child, ...child.querySelectorAll('*')]); + return descendants.filter(child => selector === '*' || selector.split(',').some(part => child.matches(part.trim()))); + } + querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; } + get innerHTML() { return ''; } + set innerHTML(_value: string) { this.children = []; } + get innerText() { return this.textContent; } + set innerText(value: string) { this.textContent = value; } + } + const document = new Element('DOCUMENT') as Element & { body: Element; activeElement: Element | null; getElementById: (id: string) => Element }; + document.body = new Element('BODY'); + document.activeElement = null; + document.appendChild(document.body); + const elements = new Map(); + const byId = (id: string) => { + if (!elements.has(id)) { + const el = new Element(id.endsWith('Input') ? 'INPUT' : 'DIV', id); + elements.set(id, el); + document.body.appendChild(el); + } + return elements.get(id)!; + }; + document.getElementById = byId; + const root = byId('csv-root'); + const table = new Element('TABLE'); + const head = new Element('THEAD'); + const body = new Element('TBODY'); + const rows = new Map(); + if (options.header !== false) { table.appendChild(head); } + table.appendChild(body); + root.appendChild(table); + root.dataset.columnsizes = JSON.stringify(options.persistedColumns || {}); + byId('__csvChunks').textContent = '[]'; + const widget = byId('findReplaceWidget'); + for (const id of ['findInput', 'replaceInput']) { + const input = byId(id); + document.body.children = document.body.children.filter(el => el !== input); + widget.appendChild(input); + } + const window = new Element('WINDOW') as any; + window.getComputedStyle = () => ({ fontSize: '14px' }); + const messages: any[] = []; + let savedState = options.state || {}; + class IntersectionObserver { + private readonly observed = new Set(); + observe(target: Element) { this.observed.add(target); } + unobserve(target: Element) { this.observed.delete(target); } + disconnect() { this.observed.clear(); } + takeRecords() { return []; } + } + const context = vm.createContext({ + document, window, Element, Node: Element, IntersectionObserver, + acquireVsCodeApi: () => ({ getState: () => savedState, setState: (state: any) => { savedState = state; }, postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) }), + setTimeout: () => 0, clearTimeout: () => {}, requestAnimationFrame: () => 0, + console + }); + (document as any).createElement = (tag: string) => new Element(tag.toUpperCase()); + vm.runInContext(fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'), context); + // Interaction assertions begin after the independent initialization handshake. + messages.length = 0; + const cell = (row: number, col: number, value: string, header = false) => { + const el = new Element(header ? 'TH' : 'TD'); + el.rect = { left: col * 100, right: (col + 1) * 100, top: row * 30, bottom: (row + 1) * 30, width: 100, height: 30 }; + el.style.width = ''; el.style.minWidth = '8ch'; el.style.maxWidth = '100ch'; el.style.boxSizing = ''; + el.setAttribute('data-row', String(row)); + el.setAttribute('data-col', String(col)); + el.textContent = value; + const key = `${header}:${row}`; + if (!rows.has(key)) { + const tr = new Element('TR'); + (header ? head : body).appendChild(tr); + rows.set(key, tr); + } + rows.get(key)!.appendChild(el); + return el; + }; + (document as any).elementFromPoint = (x: number, y: number) => table.querySelectorAll('td, th').find(cell => { + const rect = cell.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }) ?? table; + const dispatch = (target: Element, type: string, options: any = {}) => { + const event: any = { + type, target, defaultPrevented: false, stopped: false, immediate: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, + stopImmediatePropagation() { this.stopped = true; this.immediate = true; }, + ...options + }; + const invoke = (el: Element, capture: boolean) => { + for (const listener of el.listeners.get(type) || []) { + if (listener.capture === capture && !event.immediate) { listener.callback(event); } + } + }; + invoke(document, true); + if (!event.stopped) { invoke(target, false); } + for (let parent = target.parentElement; parent && !event.stopped; parent = parent.parentElement) { invoke(parent, false); } + return event; + }; + return { context, cell, byId, document, table, messages, dispatch, select: (startRow: number, startCol: number, endRow = startRow, endCol = startCol) => { + vm.runInContext(`selectRange({ row: ${startRow}, col: ${startCol} }, { row: ${endRow}, col: ${endCol} }); anchorCell = currentSelection[0];`, context); + }}; +} diff --git a/src/test/bounded-csv-reader.test.ts b/src/test/bounded-csv-reader.test.ts new file mode 100644 index 0000000..56bfe9e --- /dev/null +++ b/src/test/bounded-csv-reader.test.ts @@ -0,0 +1,157 @@ +import assert from 'assert'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it } from 'node:test'; +import Papa from 'papaparse'; +import { previewLimits, PreviewHistory, readCsvPreviewPage } from '../bounded-csv-reader'; + +async function fixture(value: string | Buffer, run: (file: string) => Promise) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'csv-bounded-')); + const file = path.join(directory, 'source.csv'); + try { + await fs.writeFile(file, value); + await run(file); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +} + +describe('Bounded CSV reader', () => { + for (const delimiter of [',', '\t', '|']) { + for (const readBytes of [1, 2, 3, 7, 64]) { + it(`preserves UTF-8, CRLF and quoted boundaries with ${JSON.stringify(delimiter)} / ${readBytes}-byte reads`, async () => { + const data = [['name', 'value'], ['éä😀', `inside${delimiter}field`], ['two\r\nlines', 'a"b'], ['\uFEFFintentional', 'end']]; + const source = '\uFEFF' + Papa.unparse(data, { delimiter, newline: '\r\n' }) + '\r\n'; + await fixture(source, async file => { + const actual: string[][] = []; + let offset = 0; + let fileVersion: string | undefined; + let done = false; + while (!done) { + const page = await readCsvPreviewPage(file, { delimiter, offset, readBytes, maxRows: 1, expectedVersion: fileVersion }); + actual.push(...page.rows); + assert.ok(page.nextOffset > offset); + offset = page.nextOffset; + fileVersion = page.version; + done = page.done; + } + assert.deepStrictEqual(actual, data); + assert.strictEqual(offset, Buffer.byteLength(source)); + }); + }); + } + } + + for (const source of ['', '\uFEFF', '\n', '\r\n', 'a,b\r', 'a,b\nc,d', 'a,b\n\n']) { + it(`handles EOF and empty records in ${JSON.stringify(source)}`, async () => { + await fixture(source, async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', readBytes: 1 }); + const expected = source === '' || source === '\uFEFF' ? [] + : source === '\n' || source === '\r\n' ? [['']] + : source === 'a,b\nc,d' ? [['a', 'b'], ['c', 'd']] + : source === 'a,b\n\n' ? [['a', 'b'], ['']] + : [['a', 'b']]; + assert.deepStrictEqual(page.rows, expected); + assert.ok(page.done); + }); + }); + } + + it('reads only a bounded prefix and never a whole large file', async () => { + const source = 'alpha,beta\n'.repeat(200000); + await fixture(source, async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 1000 }); + assert.strictEqual(page.rows.length, 1000); + assert.ok(!page.done); + assert.ok(page.bytesRead <= previewLimits.readBytes + 3); + assert.ok(page.bytesRead < Buffer.byteLength(source) / 10); + }); + }); + + it('caps source bytes and cells independently of the requested row count', async () => { + const source = `${'x'.repeat(200000)}\n`.repeat(4); + await fixture(source, async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 10000 }); + assert.strictEqual(page.rows.length, 2); + assert.strictEqual(page.nextOffset, 400002); + assert.ok(page.bytesRead <= previewLimits.pageBytes + previewLimits.recordBytes + previewLimits.readBytes + 3); + }); + await fixture(`${Array(1000).fill('x').join(',')}\n`.repeat(30), async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 10000 }); + assert.strictEqual(page.rows.length, 20); + assert.strictEqual(page.rows.flat().length, previewLimits.pageCells); + assert.ok(!page.done); + }); + }); + + for (const [source, error] of [ + ['a,"unfinished\n', /Unterminated quoted record/], + ['a,"closed"junk\n', /Malformed quoted field/], + ['x'.repeat(previewLimits.recordBytes + 1), /No partial record was displayed/], + [Array(1001).fill('x').join(','), /No fields were truncated/] + ] as const) { + it(`rejects malformed/oversized input visibly (${error.source})`, async () => { + await fixture(source, file => assert.rejects(readCsvPreviewPage(file, { delimiter: ',' }), error)); + }); + } + + it('rejects non-UTF8, UTF16 BOMs, NULs and unsupported separators without guessing', async () => { + for (const bytes of [Buffer.from([0x84, 10]), Buffer.from([0xFF, 0xFE, 97, 0]), Buffer.from('a\0b')]) { + await fixture(bytes, file => assert.rejects(readCsvPreviewPage(file, { delimiter: ',' }), /UTF-8/)); + } + await fixture('a,b', file => assert.rejects(readCsvPreviewPage(file, { delimiter: '||' }), /single ASCII separator/)); + }); + + it('detects source changes between pages', async () => { + await fixture('a,b\n1,2\n', async file => { + const first = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 1 }); + await fs.appendFile(file, '3,4\n'); + await assert.rejects(readCsvPreviewPage(file, { delimiter: ',', offset: first.nextOffset, expectedVersion: first.version }), /file changed/); + }); + }); + + it('closes the file descriptor after success, cancellation and malformed records', async () => { + const originalOpen = fs.open; + const handles: Array<{ closed: number }> = []; + (fs as any).open = async (...args: Parameters) => { + const handle = await originalOpen(...args); + const state = { closed: 0 }; + handles.push(state); + return new Proxy(handle, { + get(target, key) { + if (key === 'close') { + return async () => { state.closed++; await target.close(); }; + } + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + }; + try { + await fixture('a,b\n1,2\n', async file => { + await readCsvPreviewPage(file, { delimiter: ',' }); + let checks = 0; + await assert.rejects(readCsvPreviewPage(file, { delimiter: ',', readBytes: 1, cancelled: () => ++checks > 2 }), /cancelled/); + }); + await fixture('"unfinished', file => assert.rejects(readCsvPreviewPage(file, { delimiter: ',' }), /Unterminated/)); + assert.deepStrictEqual(handles.map(handle => handle.closed), [1, 1, 1]); + } finally { + fs.open = originalOpen; + } + }); + + it('retains only a bounded navigation history', () => { + const history = new PreviewHistory(); + for (let index = 1; index < 1000; index++) { + history.next({ offset: index * 100, record: index }); + } + assert.strictEqual(history.size, previewLimits.history); + let previous = 0; + while (history.canGoBack) { history.previous(); previous++; } + assert.strictEqual(previous, previewLimits.history - 1); + history.reset(); + assert.deepStrictEqual(history.current, { offset: 0, record: 0 }); + assert.strictEqual(history.size, 1); + }); +}); diff --git a/src/test/clipboard-column.test.ts b/src/test/clipboard-column.test.ts new file mode 100644 index 0000000..22c8503 --- /dev/null +++ b/src/test/clipboard-column.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +import { parseClipboardMatrix, serializeClipboardMatrix } from '../clipboard'; + +const select = (view: ReturnType, start: any, end = start) => { + start.dispatchEvent(new view.window.MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 50 })); + if (end !== start) { end.dispatchEvent(new view.window.MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 50 })); } + end.dispatchEvent(new view.window.MouseEvent('mouseup', { bubbles: true, button: 0 })); +}; +const copy = (view: ReturnType, target: any, route: string) => { + if (route === 'copy' || route === 'cut') { + const event = new view.window.Event(route, { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; + } + if (route === 'keyboard-copy' || route === 'keyboard-cut') { + const event = new view.window.KeyboardEvent('keydown', { key: route === 'keyboard-copy' ? 'c' : 'x', metaKey: true, bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; + } + target.dispatchEvent(new view.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 50 })); + const label = route === 'context-copy' ? 'Copy' : 'Cut'; + const item = Array.from(view.window.document.querySelectorAll('#contextMenu div')).find((element: any) => element.textContent === label) as any; + assert.ok(item); + item.click(); +}; + +describe('Whole-column clipboard and the append row', () => { + it('copies ordinary header columns without quotes or the virtual append cell', async () => { + for (const separator of [',', '\t']) { + const host = await createEditorHarness({ rows: [['Name'], ['alpha'], ['beta']], separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + const header = view.cell(0, 0); + select(view, header); + copy(view, header, 'copy'); + const message = view.messages.find(item => item.type === 'copyCells'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(message.matrix)), [['Name'], ['alpha'], ['beta']]); + assert.strictEqual(serializeClipboardMatrix(message.matrix, separator), 'Name\r\nalpha\r\nbeta'); + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + } + }); + + it('quotes only the real complex/empty values in a selected column and preserves every real source row', async () => { + for (const separator of [',', '\t']) { + const host = await createEditorHarness({ rows: [['Name', 'Other'], ['plain', 'value'], ['', 'keep'], ['with,comma', 'value'], ['last', 'value']], separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + const header = view.cell(0, 0); + select(view, header); + copy(view, header, 'keyboard-copy'); + const matrix = view.messages.find(item => item.type === 'copyCells').matrix; + assert.strictEqual(serializeClipboardMatrix(matrix, separator), 'Name\r\nplain\r\n""\r\n"with,comma"\r\nlast'); + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix(matrix, separator), separator), [['Name'], ['plain'], [''], ['with,comma'], ['last']]); + assert.strictEqual(view.window.document.querySelector('#csv-root').dataset.realrowcount, '5'); + } finally { view.close(); } + } + }); + + it('excludes ghost rows from range and Select All Copy/Cut routes, and consumes ghost-only copies without touching clipboard', async () => { + for (const route of ['copy', 'cut', 'keyboard-copy', 'keyboard-cut', 'context-copy', 'context-cut']) { + for (const selection of ['range', 'all', 'ghost']) { + const host = await createEditorHarness({ rows: [['Name', 'Other'], ['', ''], ['last', 'value']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + const ghost = view.cell(3, 0); + assert.ok(ghost); + if (selection === 'all') { + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + view.key('a', { metaKey: true }); + } else if (selection === 'range') { + select(view, view.cell(1, 0), ghost); + } else { + view.window.editingTestApi.setSingleSelection(ghost); + } + const event = copy(view, ghost, route); + const messages = view.messages.filter(item => item.type === 'copyCells'); + if (selection === 'ghost') { + assert.strictEqual(messages.length, 0, `${route}: do not send a clipboard write for append-row-only selection`); + if (event) { assert.strictEqual(event.defaultPrevented, true, 'native empty copy must not overwrite the existing system clipboard'); } + } else { + assert.strictEqual(messages.length, 1); + const expected = selection === 'all' ? [['Name', 'Other'], ['', ''], ['last', 'value']] : [[''], ['last']]; + assert.deepStrictEqual(JSON.parse(JSON.stringify(messages[0].matrix)), expected, route); + assert.strictEqual(messages[0].cut, route.includes('cut')); + } + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + } + } + }); + it('uses the full source count instead of treating the last loaded chunk row as a ghost', async () => { + const rows = [['Name'], ...Array.from({ length: 1002 }, (_value, index) => [`row-${index}`])]; + const host = await createEditorHarness({ rows, remote: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.window.document.querySelector('#csv-root').dataset.realrowcount, '1003'); + const header = view.cell(0, 0); + select(view, header); + copy(view, header, 'copy'); + const first = view.messages.filter(item => item.type === 'copyCells').slice(-1)[0]; + assert.strictEqual(first.matrix.length, 1001); + assert.strictEqual(first.matrix[1000][0], 'row-999', 'the last loaded real source row is retained'); + let cursor = view.messages.length; + view.window.__csvLoadNextChunk(); + for (let guard = 0; guard < 5 && !view.cell(1003, 0); guard++) { + const requests = view.messages.slice(cursor).filter(item => item.type === 'requestChunk'); + cursor = view.messages.length; + for (const request of requests) { + await host.receive(request); + const response = host.messages.filter(item => item.type === 'chunkData' && item.requestId === request.requestId).slice(-1)[0]; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: response })); + view.window.__csvLoadNextChunk(); + } + } + assert.ok(view.cell(1003, 0), 'the remote append row is eventually rendered'); + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + select(view, header); + copy(view, header, 'copy'); + const complete = view.messages.filter(item => item.type === 'copyCells').slice(-1)[0]; + assert.strictEqual(complete.matrix.length, 1003); + assert.strictEqual(complete.matrix[1002][0], 'row-1001'); + } finally { view.close(); } + }); + + it('does not copy the only ghost cell in an empty file', async () => { + const host = await createEditorHarness({ rows: [] }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.window.document.querySelector('#csv-root').dataset.realrowcount, '0'); + const ghost = view.cell(0, 0); + view.window.editingTestApi.setSingleSelection(ghost); + const event = copy(view, ghost, 'cut'); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.messages.some(item => item.type === 'copyCells'), false); + assert.strictEqual(host.document.getText(), ''); + } finally { view.close(); } + }); + +}); diff --git a/src/test/clipboard.test.ts b/src/test/clipboard.test.ts new file mode 100644 index 0000000..8d2eb36 --- /dev/null +++ b/src/test/clipboard.test.ts @@ -0,0 +1,124 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import Papa from 'papaparse'; +import { parseClipboardMatrix, serializeClipboardMatrix } from '../clipboard'; + +// Independent expectations exercise the same codec used by the extension host. +describe('Clipboard matrix fidelity', () => { + it('copies ordinary single-cell strings as plain text for Search and other text inputs', () => { + for (const value of ['hello', 'hello world', 'SKU-123', '00123', 'https://example.test/path?query=one&other=two']) { + assert.strictEqual(serializeClipboardMatrix([[value]], ','), value); + assert.strictEqual(serializeClipboardMatrix([[value]], '\t'), value); + } + }); + + it('does not add quotes to ordinary fields in a copied range', () => { + const matrix = [['name', 'value'], ['alpha', '42']]; + assert.strictEqual(serializeClipboardMatrix(matrix, ','), 'name,value\r\nalpha,42'); + assert.strictEqual(serializeClipboardMatrix(matrix, '\t'), 'name\tvalue\r\nalpha\t42'); + }); + + it('quotes complex fields without wrapping their ordinary neighbors', () => { + assert.strictEqual( + serializeClipboardMatrix([['hello', 'with,comma', 'say "hi"', 'line\nvalue']], ','), + 'hello,"with,comma","say ""hi""","line\nvalue"' + ); + }); + + it('keeps alternate separators inside one copied cell during automatic paste detection', () => { + for (const delimiter of [',', '\t', '|', ';', '^']) { + for (const value of ['with,comma', 'with\ttab', 'with;semicolon', 'with|pipe']) { + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix([[value]], delimiter), delimiter), [[value]]); + } + } + }); + + it('does not guess separators from inside later quoted fields in a copied range', () => { + for (const delimiter of [',', '\t', '|', ';', '^']) { + for (const value of ['a,b,c', 'a\tb\tc', 'a;;b', 'a||b']) { + const matrix = [['hello', value], ['ordinary text', value]]; + const copied = serializeClipboardMatrix(matrix, delimiter); + assert.deepStrictEqual(Papa.parse(copied, { delimiter }).data, matrix); + assert.deepStrictEqual(parseClipboardMatrix(copied, delimiter), matrix); + } + } + }); + + it('preserves mixed punctuation when copying between CSV and TSV documents', () => { + const matrix = [['a"b', 'a^b'], ['trailing ', ',;|\t']]; + for (const sourceDelimiter of [',', '\t']) { + const copied = serializeClipboardMatrix(matrix, sourceDelimiter); + for (const targetDelimiter of [',', '\t']) { + assert.deepStrictEqual(parseClipboardMatrix(copied, targetDelimiter), matrix); + } + } + }); + + it('preserves blank records at the end of a single copied column', () => { + const matrix = [['hello'], [''], ['']]; + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix(matrix, ','), ','), matrix); + }); + + for (const delimiter of [',', '\t', '|', ';', '^']) { + it(`preserves cell and record boundaries with delimiter ${JSON.stringify(delimiter)}`, () => { + const matrix = [ + ['comma,value', 'line one\nline two', 'a "literal" quote', ''], + ['tab\tvalue', 'windows\r\nnewline', ' whitespace ', ''], + ['', '', '', ''] + ]; + const text = serializeClipboardMatrix(matrix, delimiter); + assert.deepStrictEqual(Papa.parse(text, { delimiter }).data, matrix); + assert.deepStrictEqual(parseClipboardMatrix(text, delimiter), matrix); + }); + } + + it('keeps each special value in a single cell', () => { + for (const value of ['comma,value', 'line\nvalue', '"literal"', ' ', '\t', '\r\n']) { + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix([[value]], ','), ','), [[value]]); + } + }); + + it('preserves one empty cell and rows made entirely of empty cells', () => { + for (const matrix of [[['']], [[''], ['']], [['', ''], ['', '']]]) { + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix(matrix, '\t'), '\t'), matrix); + } + }); + + it('accepts external spreadsheet TSV in a CSV document', () => { + assert.deepStrictEqual(parseClipboardMatrix('one\t"two\nlines"\t\r\nthree\tfour\t\r\n', ','), [ + ['one', 'two\nlines', ''], ['three', 'four', ''] + ]); + }); + + it('removes a terminal parser sentinel while preserving intentional empty records', () => { + assert.deepStrictEqual(parseClipboardMatrix('one,two\n,,\n', ','), [['one', 'two', ''], ['', '', '']]); + assert.deepStrictEqual(parseClipboardMatrix('one\n""\n', ','), [['one'], ['']]); + assert.deepStrictEqual(parseClipboardMatrix('"\n"', ','), [['\n']]); + assert.deepStrictEqual(parseClipboardMatrix('', ','), []); + }); + it('keeps external literal quotes and quoted later fields out of delimiter guesses', () => { + assert.deepStrictEqual(parseClipboardMatrix('left"quote\tright\nother\tvalue', ','), [['left"quote', 'right'], ['other', 'value']]); + assert.deepStrictEqual(parseClipboardMatrix('plain\t"a,b,c"\nother\t"d,e,f"', ','), [['plain', 'a,b,c'], ['other', 'd,e,f']]); + assert.deepStrictEqual(parseClipboardMatrix('plain||"a,b,c"\nother||""', '||'), [['plain', 'a,b,c'], ['other', '']]); + }); + + it('round-trips diverse rectangular matrices across common delimiters and within custom formats', () => { + const values = ['plain', '42', '', 'a,b', 'a\tb', 'a;b', 'a|b', 'say "hi"', 'a\nb', 'a\r\nb', ' edge ', '\uFEFFvalue', 'a^b', 'a::b', 'a||b']; + const common = [',', '\t', ';', '|']; + let seed = 1729; + const next = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed; }; + for (let sample = 0; sample < 100; sample++) { + const height = 1 + next() % 4; + const width = 1 + next() % 4; + const matrix = Array.from({ length: height }, () => Array.from({ length: width }, () => values[next() % values.length])); + for (const delimiter of [...common, '^', '||', '::']) { + const text = serializeClipboardMatrix(matrix, delimiter); + assert.deepStrictEqual(Papa.parse(text, { delimiter }).data, matrix); + for (const target of common.includes(delimiter) ? common : [delimiter]) { + assert.deepStrictEqual(parseClipboardMatrix(text, target), matrix, `source ${JSON.stringify(delimiter)}, target ${JSON.stringify(target)}, sample ${sample}`); + } + } + } + }); + +}); diff --git a/src/test/column-filter-view.test.ts b/src/test/column-filter-view.test.ts new file mode 100644 index 0000000..8e35589 --- /dev/null +++ b/src/test/column-filter-view.test.ts @@ -0,0 +1,426 @@ +import assert from 'assert'; +import Papa from 'papaparse'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); +const filter = [{ column: 1, operator: 'equals', value: 'keep' }]; +type Host = Awaited>; +type View = ReturnType; +const rows = [['Name', 'State'], ['one', 'keep'], ['hidden', 'drop'], ['three', 'KEEP']]; + +async function fixture(options: Parameters[0] = {}) { + const host = await createEditorHarness({ rows, filters: filter, ...options }); + const view = createWebviewDom(host.panel.webview.html); + return { host, view }; +} +async function refresh(host: Awaited>, view: ReturnType) { + const prepare = host.messages.slice().reverse().find((message: any) => message.type === 'prepareViewRefresh'); + assert.ok(prepare); + const start = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare })); + for (const message of view.messages.slice(start)) { await host.receive(message); } +} + +async function submitFilters(host: Host, view: View, filters: any[]) { + const originalHtml = host.panel.webview.html; + const table = view.window.document.querySelector('table'); + const start = view.messages.length; + for (const remove of Array.from(view.window.document.querySelectorAll('[data-remove-filter]')) as any[]) { remove.click(); } + for (const condition of filters) { + view.window.csvColumnFilters.add(condition.column); + const controls = Array.from(view.window.document.querySelectorAll('.column-filter-row')) as any[]; + const row = controls[controls.length - 1]; + const operator = row.querySelector('[data-filter-operator]'); + operator.value = condition.operator; + operator.dispatchEvent(new view.window.Event('change', { bubbles: true })); + const value = row.querySelector('[data-filter-value]'); + if (value) { + value.value = condition.value; + value.dispatchEvent(new view.window.Event('input', { bubbles: true })); + } + } + await settleControls(host, view, start); + const request = view.messages.slice(start).filter((message: any) => message.type === 'updateColumnFilters').pop(); + assert.ok(request, 'Inline controls submit one current filter snapshot'); + const result = host.messages.filter((message: any) => message.type === 'columnFiltersResult' && message.revision === request.revision).pop(); + assert.ok(result, 'Every live snapshot receives its matching revision result'); + assert.strictEqual(host.panel.webview.html, originalHtml, 'Live filter changes must not replace the entire webview'); + assert.strictEqual(view.window.document.querySelector('table'), table, 'Delegated table listeners keep their original table'); + return result; +} + +async function settleControls(host: Host, view: View, start = 0) { + await new Promise(resolve => view.window.setTimeout(resolve, 140)); + let clientCursor = start; + let hostCursor = host.messages.length; + while (clientCursor < view.messages.length) { + const message = view.messages[clientCursor++]; + await host.receive(message); + for (const reply of host.messages.slice(hostCursor)) { + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: reply })); + } + hostCursor = host.messages.length; + } +} + +describe('Full-source filtered view operations', () => { + it('filters beyond the initial source chunk and shows editable independent conditions', async () => { + const source = [['Name', 'State'], ...Array.from({ length: 2004 }, (_, row) => [`row${row}`, row === 1500 || row === 1998 ? 'keep' : 'drop'])]; + const { host, view } = await fixture({ rows: source }); + try { + assert.strictEqual(view.cell(1501, 0).textContent, 'row1500'); + assert.strictEqual(view.cell(1999, 0).textContent, 'row1998'); + assert.strictEqual(view.cell(1999, -1).textContent, '1999'); + assert.strictEqual(view.cell(1, 0), null); + assert.ok(view.window.document.getElementById('filterStatus').textContent.includes('2 of 2004 rows')); + assert.strictEqual(view.window.document.querySelectorAll('.column-filter-row').length, 1); + assert.strictEqual(view.window.document.querySelector('[data-filter-value]').value, 'keep'); + assert.ok(view.window.document.querySelector('[data-remove-filter]')); + assert.ok(view.window.document.getElementById('clearAllFilters')); + assert.ok(view.window.document.getElementById('addColumnFilter')); + assert.strictEqual(view.cell(0, 1).getAttribute('data-filter-column'), 'active'); + assert.deepStrictEqual(host.rows(), source); + } finally { view.close(); } + }); + + it('keeps original absolute IDs in remote filtered chunks', async () => { + const source = [['Name', 'State'], ...Array.from({ length: 3000 }, (_, row) => [`row${row}`, row % 2 === 0 ? 'keep' : 'drop'])]; + const { host, view } = await fixture({ rows: source }); + try { + assert.ok(view.cell(1999, 0)); + assert.strictEqual(view.cell(2001, 0), null); + view.window.__csvLoadNextChunk(); + const request = view.messages.slice().reverse().find((message: any) => message.type === 'requestChunk'); + assert.strictEqual(request.filterEpoch, 0); + assert.strictEqual(request.session, JSON.parse(view.window.document.getElementById('__csvFilters').textContent).session); + await host.receive(request); + const response = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData'); + assert.strictEqual(response.filterEpoch, 0); + assert.strictEqual(response.session, request.session); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: response })); + view.window.__csvLoadNextChunk(); + assert.strictEqual(view.cell(2001, 0).textContent, 'row2000'); + assert.strictEqual(view.cell(2001, -1).textContent, '2001'); + assert.strictEqual(view.cell(2002, 0), null); + } finally { view.close(); } + }); + + it('copies and cuts only selected visible source rows across gaps', async () => { + const { host, view } = await fixture(); + try { + view.window.editingTestApi.selectRange({ row: 1, col: 0 }, { row: 3, col: 1 }); + view.cell(1, 0).dispatchEvent(new view.window.Event('copy', { bubbles: true, cancelable: true })); + const copy = view.messages.find((message: any) => message.type === 'copyCells'); + assert.deepStrictEqual(Array.from(copy.rowIds), [1, 3]); + await host.receive(copy); + assert.deepStrictEqual(Papa.parse(host.clipboardText).data, [['one', 'keep'], ['three', 'KEEP']]); + await host.receive({ ...copy, cut: true }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['', ''], ['hidden', 'drop'], ['', '']]); + } finally { view.close(); } + }); + + it('pastes a matrix across visible rows and appends at the physical end without touching hidden rows', async () => { + const { host, view } = await fixture(); + try { + await host.receive({ type: 'pasteCells', text: 'first\nsecond\nappended', anchorRow: 1, anchorCol: 0 }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['first', 'keep'], ['hidden', 'drop'], ['second', 'KEEP'], ['appended']]); + await host.document.save(); + assert.deepStrictEqual(Papa.parse(host.diskText).data, host.rows()); + } finally { view.close(); } + }); + + it('fills only visible cells in a selected rectangle, leaving hidden records intact', async () => { + const { host, view } = await fixture(); + try { + view.window.editingTestApi.selectRange({ row: 1, col: 0 }, { row: 3, col: 1 }); + const event = new view.window.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { getData: () => 'filled' } }); + view.cell(1, 0).dispatchEvent(event); + const paste = view.messages.find((message: any) => message.type === 'pasteCells'); + assert.strictEqual(paste.selection.rectangular, true); + await host.receive(paste); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['filled', 'filled'], ['hidden', 'drop'], ['filled', 'filled']]); + } finally { view.close(); } + }); + + it('finds and replaces only matching rows while preserving preambles and hidden values', async () => { + const source = [['metadata', 'keep'], ['Name', 'State'], ['needle one', 'keep'], ['needle hidden', 'drop'], ['needle two', 'KEEP']]; + const { host, view } = await fixture({ rows: source, hiddenRows: 1 }); + try { + await host.receive({ type: 'findMatches', requestId: 1, query: 'needle', options: {} }); + const found = host.messages.slice().reverse().find((message: any) => message.type === 'findMatchesResult'); + assert.deepStrictEqual(found.matches.map((match: any) => match.row), [2, 4]); + await host.receive({ type: 'replaceCells', replacements: found.matches.map((match: any) => ({ row: match.row, col: match.col, value: 'replaced' })) }); + assert.deepStrictEqual(host.rows(), [['metadata', 'keep'], ['Name', 'State'], ['replaced', 'keep'], ['needle hidden', 'drop'], ['replaced', 'KEEP']]); + } finally { view.close(); } + }); + + it('sorts matching slots and reorders/deletes by absolute source indices', async () => { + const source = [['Name', 'State'], ['z', 'keep'], ['hidden', 'drop'], ['a', 'KEEP']]; + const { host, view } = await fixture({ rows: source }); + try { + await host.receive({ type: 'sortColumn', index: 0, ascending: true }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['a', 'KEEP'], ['hidden', 'drop'], ['z', 'keep']]); + await host.receive({ type: 'reorderRows', indices: [3], beforeIndex: 1 }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['z', 'keep'], ['a', 'KEEP'], ['hidden', 'drop']]); + await host.receive({ type: 'deleteRows', indices: [1, 2] }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['hidden', 'drop']]); + } finally { view.close(); } + }); + + it('moves filters with inserted/reordered columns and clears a deleted column predicate', async () => { + const { host, view } = await fixture(); + try { + const getFilters = () => CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any); + await host.receive({ type: 'insertColumn', index: 0 }); + assert.strictEqual(getFilters()[0].column, 2); + await host.receive({ type: 'reorderColumns', indices: [2], beforeIndex: 0 }); + assert.strictEqual(getFilters()[0].column, 0); + await host.receive({ type: 'deleteColumn', index: 0 }); + assert.deepStrictEqual(getFilters(), []); + assert.ok(host.rows().some(row => row.includes('hidden'))); + } finally { view.close(); } + }); + + it('navigates/selects visible rows and freezes visible leading rows with a toolbar inset', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 1 }); + try { + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + view.key('ArrowDown'); + assert.strictEqual(view.window.document.activeElement, view.cell(3, 0)); + view.key('ArrowUp', { shiftKey: true }); + assert.strictEqual(view.window.document.querySelectorAll('td.selected[data-col="0"]').length, 2); + const status = view.window.document.getElementById('filterStatus'); + status.getBoundingClientRect = () => ({ height: 20 }); + for (const row of Array.from(view.window.document.querySelectorAll('tr')) as any[]) { row.getBoundingClientRect = () => ({ height: 30 }); } + // JSDOM drops border shorthand containing a CSS variable; model the browser's 1px border. + view.window.document.querySelector('th, td').style.borderTopWidth = '1px'; + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(0, 0).style.top, '19.5px'); + assert.strictEqual(view.cell(1, 0).style.top, '49.5px'); + assert.strictEqual(view.cell(3, 0).style.top, '79.5px'); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row')); + } finally { view.close(); } + }); + + it('handles zero matches with headers off and keeps the append row at the full-source end', async () => { + const { host, view } = await fixture({ header: false, filters: [{ column: 1, operator: 'equals', value: 'absent' }], frozenRows: 10 }); + try { + assert.strictEqual(view.window.document.querySelectorAll('tbody tr').length, 1); + assert.ok(view.cell(4, 0)); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row')); + await host.receive({ type: 'pasteCells', text: 'new', anchorRow: 4, anchorCol: 0 }); + assert.deepStrictEqual(host.rows().slice(0, 4), rows); + assert.strictEqual(host.rows()[4][0], 'new'); + } finally { view.close(); } + }); + + it('persists live filter text safely and clears all without replacing the editor', async () => { + const { host, view } = await fixture({ filters: [] }); + try { + const input = ''; + const conditions = [{ column: 0, operator: 'equals', value: input }]; + const result = await submitFilters(host, view, conditions); + assert.ok(!result.error && !result.errors?.length); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), conditions); + assert.strictEqual(view.window.document.querySelector('[data-filter-value]').value, input); + assert.strictEqual(view.window.document.querySelectorAll('#filterStatus script').length, 0); + const start = view.messages.length; + view.window.document.getElementById('clearAllFilters').click(); + await settleControls(host, view, start); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), []); + assert.ok(view.window.document.getElementById('filterStatus').hidden); + assert.deepStrictEqual(host.rows(), rows); + assert.strictEqual(host.quickPickCalls.length, 0); + assert.strictEqual(host.inputBoxCalls.length, 0); + } finally { view.close(); } + }); + + it('re-evaluates an edited filter value using the safe commit-before-refresh path', async () => { + const { host, view } = await fixture(); + try { + await host.receive({ type: 'editCell', row: 1, col: 1, value: 'drop' }); + await refresh(host, view); + assert.ok(!host.panel.webview.html.includes('data-row="1"')); + assert.strictEqual(host.rows()[1][1], 'drop'); + assert.deepStrictEqual(host.rows()[2], ['hidden', 'drop']); + } finally { view.close(); } + }); + it('preserves exact metadata and hidden record formatting during filtered paste, fill and sort', async () => { + const text = '"metadata","preserve"\r\nName,"State"\r\n"z","keep"\r\nNaN,"drop"\r\n"a",KEEP\r\n'; + const { host, view } = await fixture({ text, hiddenRows: 1 }); + try { + await host.receive({ type: 'pasteCells', text: 'changed', anchorRow: 2, anchorCol: 0 }); + assert.strictEqual(host.document.getText(), '"metadata","preserve"\r\nName,"State"\r\n"changed","keep"\r\nNaN,"drop"\r\n"a",KEEP\r\n'); + await host.receive({ type: 'sortColumn', index: 0, ascending: true }); + assert.strictEqual(host.document.getText(), '"metadata","preserve"\r\nName,"State"\r\n"a",KEEP\r\nNaN,"drop"\r\n"changed","keep"\r\n'); + await host.receive({ type: 'pasteCells', text: 'new,keep', anchorRow: 5, anchorCol: 0 }); + assert.ok(host.document.getText().startsWith('"metadata","preserve"\r\nName,"State"\r\n"a",KEEP\r\nNaN,"drop"\r\n"changed","keep"\r\n')); + await host.receive({ type: 'pasteCells', text: 'filled', anchorRow: 2, anchorCol: 0, selection: { minRow: 2, maxRow: 4, minCol: 0, maxCol: 0, rectangular: true } }); + assert.ok(host.document.getText().includes('NaN,"drop"\r\n')); + assert.ok(host.document.getText().startsWith('"metadata","preserve"\r\nName,"State"\r\n')); + } finally { view.close(); } + }); + + it('preserves raw tokens for hidden records during filtered row/column structural actions', async () => { + const text = '"Name","State"\n"one","keep"\nhidden,"drop"\n"three",KEEP\n'; + const { host, view } = await fixture({ text }); + try { + await host.receive({ type: 'deleteRows', indices: [1, 3] }); + assert.strictEqual(host.document.getText(), '"Name","State"\nhidden,"drop"\n'); + await host.receive({ type: 'insertColumns', index: 0, count: 2 }); + assert.ok(host.document.getText().includes(',,hidden,"drop"\n')); + await host.receive({ type: 'deleteColumns', indices: [0, 1] }); + assert.strictEqual(host.document.getText(), '"Name","State"\nhidden,"drop"\n'); + } finally { view.close(); } + }); + + it('retains quoted tabs/newlines in filtered TSV clipboard and paste flows', async () => { + const source = [['Name', 'State'], ['tab\tand\nnewline', 'keep'], ['hidden', 'drop'], ['three', 'keep']]; + const { host, view } = await fixture({ rows: source, separator: '\t' }); + try { + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + view.cell(1, 0).dispatchEvent(new view.window.Event('copy', { bubbles: true, cancelable: true })); + await host.receive(view.messages.find((message: any) => message.type === 'copyCells')); + await host.receive({ type: 'pasteCells', text: host.clipboardText, anchorRow: 3, anchorCol: 0 }); + assert.strictEqual(host.rows()[3][0], 'tab\tand\nnewline'); + assert.deepStrictEqual(host.rows()[2], ['hidden', 'drop']); + } finally { view.close(); } + }); + + it('leaves filter-bar keyboard activation native and tab navigation follows visible rows', async () => { + const { view } = await fixture(); + try { + view.window.editingTestApi.setSingleSelection(view.cell(1, 1)); + view.key('Tab'); + assert.strictEqual(view.window.document.activeElement, view.cell(3, 0)); + view.key('Tab', { shiftKey: true }); + assert.strictEqual(view.window.document.activeElement, view.cell(1, 1)); + const clear = view.window.document.getElementById('clearAllFilters'); + clear.focus(); + const event = new view.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + clear.dispatchEvent(event); + assert.strictEqual(event.defaultPrevented, false); + assert.strictEqual(view.window.document.querySelector('[contenteditable="true"]'), null); + clear.click(); + await new Promise(resolve => view.window.setTimeout(resolve, 140)); + assert.ok(view.messages.some((message: any) => message.type === 'updateColumnFilters' && message.filters.length === 0)); + } finally { view.close(); } + }); + + it('rejects oversized live snapshots atomically and isolates saved conditions per URI', async () => { + const { host, view } = await fixture(); + try { + const saved = () => CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any); + const beforeRows = view.window.document.querySelector('table').innerHTML; + await host.receive({ type: 'updateColumnFilters', session: JSON.parse(view.window.document.getElementById('__csvFilters').textContent).session, revision: 1, filters: [{ column: 1, operator: 'contains', value: 'x'.repeat(4097) }] }); + const invalid = host.messages.find((message: any) => message.type === 'columnFiltersResult' && message.revision === 1); + assert.ok(invalid); + assert.ok(invalid.error || invalid.errors?.length); + assert.strictEqual(invalid.filters?.[0]?.value ?? filter[0].value, filter[0].value); + assert.deepStrictEqual(saved(), filter); + assert.strictEqual(view.window.document.querySelector('table').innerHTML, beforeRows); + assert.ok(!host.messages.some((message: any) => message.type === 'prepareViewRefresh')); + const other = { toString: () => 'file:///other.csv' } as any; + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, other), []); + await CsvEditorProvider.setColumnFiltersForUri(host.context as any, other, [{ column: 0, operator: 'empty', value: '' }]); + await host.receive({ type: 'updateColumnFilters', session: JSON.parse(view.window.document.getElementById('__csvFilters').textContent).session, revision: 2, filters: [] }); + assert.deepStrictEqual(saved(), []); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, other), [{ column: 0, operator: 'empty', value: '' }]); + } finally { view.close(); } + }); + + it('removes only one same-column AND condition and commits a multiline draft before live refresh', async () => { + const conditions = [...filter, { column: 0, operator: 'notEmpty', value: '' }, { column: 1, operator: 'notEquals', value: 'drop' }]; + const { host, view } = await fixture({ filters: conditions }); + try { + const cell = view.cell(1, 0); + view.start(cell); + cell.textContent = 'unsaved\nmultiline draft'; + const start = view.messages.length; + view.window.document.querySelectorAll('[data-remove-filter]')[2].click(); + await settleControls(host, view, start); + assert.strictEqual(host.rows()[1][0], 'unsaved\nmultiline draft'); + assert.deepStrictEqual(host.rows()[2], ['hidden', 'drop']); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), conditions.slice(0, 2)); + assert.strictEqual(view.window.document.querySelectorAll('.column-filter-row').length, 2); + assert.strictEqual(view.cell(1, 0).textContent, 'unsaved\nmultiline draft'); + assert.strictEqual(host.diskText.includes('unsaved'), false, 'Changing filters never saves a file'); + } finally { view.close(); } + }); + + it('aligns the last valid result when an older success was ignored before a newer invalid draft', async () => { + const { host, view } = await fixture({ filters: [] }); + try { + const table = view.window.document.querySelector('table'); + const originalHtml = host.panel.webview.html; + view.window.csvColumnFilters.add(0); + const input = view.window.document.querySelector('[data-filter-value]'); + const operator = view.window.document.querySelector('[data-filter-operator]'); + input.value = 'one'; + input.dispatchEvent(new view.window.Event('input', { bubbles: true })); + await new Promise(resolve => view.window.setTimeout(resolve, 140)); + const validRequest = view.messages.filter((message: any) => message.type === 'updateColumnFilters').pop(); + await host.receive(validRequest); + const success = host.messages.filter((message: any) => message.type === 'columnFiltersResult' && message.revision === validRequest.revision).pop(); + assert.ok(success.tableHtml); + + operator.value = 'regex'; + operator.dispatchEvent(new view.window.Event('change', { bubbles: true })); + input.value = '['; + input.dispatchEvent(new view.window.Event('input', { bubbles: true })); + await new Promise(resolve => view.window.setTimeout(resolve, 140)); + const invalidRequest = view.messages.filter((message: any) => message.type === 'updateColumnFilters').pop(); + assert.ok(invalidRequest.revision > validRequest.revision); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: success })); + assert.ok(view.cell(3, 0), 'The older success must not update a newer local draft'); + await host.receive(invalidRequest); + const invalid = host.messages.filter((message: any) => message.type === 'columnFiltersResult' && message.revision === invalidRequest.revision).pop(); + assert.ok(invalid.error || invalid.errors?.length); + assert.strictEqual(invalid.filterEpoch, success.filterEpoch); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: invalid })); + assert.strictEqual(view.cell(1, 0).textContent, 'one'); + assert.strictEqual(view.cell(3, 0), null, 'The latest invalid reply aligns the last accepted visible result'); + assert.strictEqual(view.window.document.querySelector('[data-filter-value]'), input); + assert.strictEqual(input.value, '['); + assert.strictEqual(input.getAttribute('aria-invalid'), 'true'); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), [{ column: 0, operator: 'contains', value: 'one' }]); + assert.strictEqual(view.window.document.querySelector('table'), table); + assert.strictEqual(host.panel.webview.html, originalHtml); + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + }); + + it('keeps literal whitespace as an independent same-column AND text condition', async () => { + const source = [['Name', 'State'], ['two words', 'keep'], ['twowords', 'keep'], ['another', 'drop']]; + const { host, view } = await fixture({ rows: source, filters: [] }); + try { + const conditions = [{ column: 0, operator: 'contains', value: 'two' }, { column: 0, operator: 'contains', value: ' ' }]; + const result = await submitFilters(host, view, conditions); + assert.ok(!result.error && !result.errors?.length); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), conditions); + assert.strictEqual(view.cell(1, 0).textContent, 'two words'); + assert.strictEqual(view.cell(2, 0), null); + assert.strictEqual(view.window.document.querySelectorAll('.column-filter-row').length, 2); + assert.strictEqual(view.window.document.querySelectorAll('[data-filter-value]')[1].value, ' '); + assert.deepStrictEqual(host.rows(), source); + } finally { view.close(); } + }); + + it('retains both same-column predicates through column insert, reorder and delete', async () => { + const conditions = [filter[0], { column: 1, operator: 'notEquals', value: 'drop' }, { column: 0, operator: 'notEmpty', value: '' }]; + const { host, view } = await fixture({ filters: conditions }); + try { + const saved = () => CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any); + await host.receive({ type: 'insertColumn', index: 0 }); + assert.deepStrictEqual(saved().map(condition => condition.column), [2, 2, 1]); + await host.receive({ type: 'reorderColumns', indices: [2], beforeIndex: 0 }); + assert.deepStrictEqual(saved().map(condition => condition.column), [0, 0, 2]); + await host.receive({ type: 'deleteColumn', index: 0 }); + assert.deepStrictEqual(saved(), [{ column: 1, operator: 'notEmpty', value: '' }]); + assert.ok(host.rows().some(row => row.includes('hidden'))); + } finally { view.close(); } + }); + +}); diff --git a/src/test/column-filters.test.ts b/src/test/column-filters.test.ts new file mode 100644 index 0000000..053937d --- /dev/null +++ b/src/test/column-filters.test.ts @@ -0,0 +1,281 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { RE2JS } from 're2js'; +import { + ColumnFilter, ColumnFilterOperator, FilterDataType, compileColumnFilters, + getFilterOperatorChoices, normalizeColumnFilters, rowMatchesFilters, visibleSourceRows, + remapColumnFilters, validateColumnFilterInput, MAX_COLUMN_FILTERS, MAX_FILTER_VALUE_LENGTH +} from '../column-filters'; + +const filter = (operator: ColumnFilterOperator, value: string, dataType?: FilterDataType): ColumnFilter => ({ column: 0, operator, value, ...(dataType === undefined ? {} : { dataType }) }); + +function matchingValues(values: string[], predicate: ColumnFilter): string[] { + const matches = compileColumnFilters([predicate]); + return values.filter(value => matches([value])); +} + +describe('Column filter predicates and identities', () => { + it('combines case-insensitive text predicates and exact empty-cell predicates', () => { + assert.ok(rowMatchesFilters(['Alpha Beta', 'YES', ''], [{ column: 0, operator: 'contains', value: 'BETA' }, { column: 1, operator: 'equals', value: 'yes' }, { column: 2, operator: 'empty', value: '' }])); + assert.ok(!rowMatchesFilters(['Alpha', 'yes', ''], [filter('contains', 'beta')])); + assert.ok(rowMatchesFilters([], [{ column: 2, operator: 'empty', value: '' }])); + assert.ok(!rowMatchesFilters([''], [filter('notEmpty', '')])); + assert.ok(rowMatchesFilters([' '], [filter('notEmpty', '')])); + }); + + it('negates contains and equality without trimming or interpreting text as patterns', () => { + const values = ['Alpha Beta', 'ALPHA', 'alpha ', '', '']; + assert.deepStrictEqual(matchingValues(values, filter('notContains', 'ALPHA')), ['', '']); + assert.deepStrictEqual(matchingValues(values, filter('notEquals', 'alpha')), ['Alpha Beta', 'alpha ', '', '']); + assert.deepStrictEqual(matchingValues(values, filter('contains', '')), ['']); + assert.ok(rowMatchesFilters([], [filter('notEquals', 'alpha')])); + assert.deepStrictEqual(matchingValues(values, filter('notContains', '')), []); + assert.deepStrictEqual(matchingValues(values, filter('contains', '')), values); + }); + + it('retains literal text and empty predicates for every inferred data type', () => { + for (const type of ['string', 'number', 'date', 'boolean'] as const) { + assert.deepStrictEqual(matchingValues(['USD 1,000', 'usd 2', '1,000'], filter('contains', 'USD', type)), ['USD 1,000', 'usd 2']); + assert.ok(rowMatchesFilters(['not a scalar'], [filter('notContains', 'USD', type)])); + assert.ok(rowMatchesFilters([], [filter('empty', '', type)])); + assert.ok(rowMatchesFilters([' '], [filter('notEmpty', '', type)])); + } + }); + + it('compares numeric equivalents rather than their formatting', () => { + const values = ['1000', '1,000.00', '1e3', '1001', '1,00', '', ' ', 'NaN']; + assert.deepStrictEqual(matchingValues(values, filter('equals', '1,000', 'number')), ['1000', '1,000.00', '1e3']); + assert.deepStrictEqual(matchingValues(values, filter('notEquals', '1e3', 'number')), ['1001']); + assert.deepStrictEqual(matchingValues(['-0', '0', '0.0', '1'], filter('equals', '0', 'number')), ['-0', '0', '0.0']); + assert.deepStrictEqual(matchingValues(['1000', '1,000.00'], filter('equals', '1000')), ['1000']); + }); + + it('preserves exact numeric identity and order beyond floating-point precision', () => { + const values = ['9007199254740992', '9007199254740993', '9,007,199,254,740,993', '9.007199254740993e15', '9007199254740994', '', 'invalid']; + assert.deepStrictEqual(matchingValues(values, filter('equals', '9007199254740993', 'number')), values.slice(1, 4)); + assert.deepStrictEqual(matchingValues(values, filter('notEquals', '9007199254740993', 'number')), [values[0], values[4]]); + assert.deepStrictEqual(matchingValues(values, filter('greaterThan', '9007199254740992', 'number')), values.slice(1, 5)); + assert.deepStrictEqual(matchingValues(values, filter('lessThan', '9007199254740993', 'number')), [values[0]]); + assert.deepStrictEqual(matchingValues(['0.10000000000000000001', '0.1', '0.09999999999999999999'], filter('greaterThan', '0.1', 'number')), ['0.10000000000000000001']); + assert.deepStrictEqual(matchingValues(['-9007199254740993', '-9007199254740992'], filter('lessThan', '-9007199254740992', 'number')), ['-9007199254740993']); + assert.deepStrictEqual(matchingValues(['1e-1000', '0', '-1e-1000'], filter('equals', '0', 'number')), ['0']); + }); + + it('applies all numeric boundaries and excludes invalid or blank cells', () => { + const values = ['9', '10', '1e1', '11', '', 'not numeric', 'Infinity']; + const expected: Array<[ColumnFilterOperator, string[]]> = [ + ['greaterThan', ['11']], ['greaterThanOrEqual', ['10', '1e1', '11']], + ['lessThan', ['9']], ['lessThanOrEqual', ['9', '10', '1e1']] + ]; + for (const [operator, result] of expected) { + assert.deepStrictEqual(matchingValues(values, filter(operator, '10', 'number')), result, operator); + } + }); + + it('compares date instants consistently across timezone and date-only formats', () => { + const values = ['2024-01-02', '2024-01-02T00:00:00Z', '2024-01-01T19:00:00-05:00', '2024-01-03', '2024-02-30', '']; + assert.deepStrictEqual(matchingValues(values, filter('equals', '2024-01-02', 'date')), values.slice(0, 3)); + assert.deepStrictEqual(matchingValues(values, filter('notEquals', '2024-01-02', 'date')), ['2024-01-03']); + assert.deepStrictEqual(matchingValues(values, filter('greaterThan', '2024-01-02', 'date')), ['2024-01-03']); + assert.deepStrictEqual(matchingValues(values, filter('greaterThanOrEqual', '2024-01-02', 'date')), values.slice(0, 4)); + assert.deepStrictEqual(matchingValues(values, filter('lessThan', '2024-01-03', 'date')), values.slice(0, 3)); + assert.deepStrictEqual(matchingValues(values, filter('lessThanOrEqual', '2024-01-02', 'date')), values.slice(0, 3)); + }); + + it('compares sub-millisecond date fractions without collapsing nearby instants', () => { + const values = ['2024-01-01T00:00:00.0001Z', '2024-01-01T00:00:00.0002Z', '2023-12-31T19:00:00.000200-05:00', '2024-01-01T00:00:00.001Z', '', 'not a date']; + const query = '2024-01-01T00:00:00.0002Z'; + const expected: Array<[ColumnFilterOperator, string[]]> = [ + ['equals', values.slice(1, 3)], ['notEquals', [values[0], values[3]]], + ['greaterThan', [values[3]]], ['greaterThanOrEqual', values.slice(1, 4)], + ['lessThan', [values[0]]], ['lessThanOrEqual', values.slice(0, 3)] + ]; + for (const [operator, result] of expected) { + assert.deepStrictEqual(matchingValues(values, filter(operator, query, 'date')), result, operator); + } + assert.ok(!rowMatchesFilters([], [filter('notEquals', query, 'date')])); + const data = [['metadata'], ['Timestamp'], ...values.map(value => [value])]; + assert.deepStrictEqual(visibleSourceRows(data, 1, true, [filter('equals', query, 'date')]), [1, 3, 4]); + }); + + it('retains nanosecond equality across trailing zeros and equivalent timezone offsets', () => { + const values = ['2024-01-01T00:00:00.123456788Z', '2024-01-01T00:00:00.123456789Z', '2024-01-01T00:00:00.123456789000Z', '2024-01-01T01:00:00.123456789+01:00', '2024-01-01T00:00:00.123456790Z']; + const query = values[1]; + assert.strictEqual(validateColumnFilterInput('equals', query, 'date'), undefined); + assert.deepStrictEqual(matchingValues(values, filter('equals', query, 'date')), values.slice(1, 4)); + assert.deepStrictEqual(matchingValues(values, filter('notEquals', query, 'date')), [values[0], values[4]]); + assert.deepStrictEqual(matchingValues(values, filter('lessThan', query, 'date')), [values[0]]); + assert.deepStrictEqual(matchingValues(values, filter('greaterThan', query, 'date')), [values[4]]); + }); + + it('orders exact pre-epoch fractions across the zero epoch and equivalent zones', () => { + const values = ['1969-12-31T23:59:59.9991Z', '1969-12-31T23:59:59.9999Z', '1969-12-31T18:59:59.999900-05:00', '1970-01-01T00:00:00Z', '1970-01-01T00:00:00.0001Z']; + const query = values[1]; + assert.deepStrictEqual(matchingValues(values, filter('equals', query, 'date')), values.slice(1, 3)); + assert.deepStrictEqual(matchingValues(values, filter('lessThan', query, 'date')), [values[0]]); + assert.deepStrictEqual(matchingValues(values, filter('greaterThan', query, 'date')), values.slice(3)); + assert.deepStrictEqual(matchingValues(values, filter('lessThan', values[3], 'date')), values.slice(0, 3)); + assert.deepStrictEqual(matchingValues(['1969-12-31T23:59:59.0001Z', '1969-12-31T23:59:59.0002Z'], filter('lessThan', '1969-12-31T23:59:59.0002Z', 'date')), ['1969-12-31T23:59:59.0001Z']); + }); + + it('handles both boolean thresholds without treating false as an invalid parse', () => { + const values = ['true', 'TRUE', 'false', 'FALSE', '', 'unknown']; + assert.deepStrictEqual(matchingValues(values, filter('equals', 'true', 'boolean')), ['true', 'TRUE']); + assert.deepStrictEqual(matchingValues(values, filter('equals', 'false', 'boolean')), ['false', 'FALSE']); + assert.deepStrictEqual(matchingValues(values, filter('notEquals', 'FALSE', 'boolean')), ['true', 'TRUE']); + assert.ok(!rowMatchesFilters([], [filter('notEquals', 'true', 'boolean')])); + }); + + it('rejects invalid query scalars and unsupported ordered data types', () => { + for (const type of ['number', 'date', 'boolean'] as const) { + for (const operator of ['equals', 'notEquals'] as const) { + for (const value of ['', ' ', 'not a scalar']) { + assert.match(validateColumnFilterInput(operator, value, type) ?? '', /valid/); + assert.deepStrictEqual(matchingValues(['0', 'false', '2024-01-02', 'other', ''], filter(operator, value, type)), []); + } + } + } + for (const type of ['string', 'boolean'] as const) { + assert.match(validateColumnFilterInput('greaterThan', '1', type) ?? '', /number or date/); + assert.ok(!rowMatchesFilters(['2'], [filter('greaterThan', '1', type)])); + } + assert.strictEqual(validateColumnFilterInput('equals', ''), undefined); + assert.strictEqual(validateColumnFilterInput('contains', ''), undefined); + assert.match(validateColumnFilterInput('contains', 'x'.repeat(MAX_FILTER_VALUE_LENGTH + 1)) ?? '', /4096/); + }); + + it('offers all universal operators and ordered comparisons only for numbers and dates', () => { + const universal = ['contains', 'notContains', 'regex', 'equals', 'notEquals', 'empty', 'notEmpty']; + for (const type of ['string', 'boolean'] as const) { + assert.deepStrictEqual(getFilterOperatorChoices(type).map(choice => choice.operator), universal); + } + const ordered = ['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; + for (const type of ['number', 'date'] as const) { + const choices = getFilterOperatorChoices(type); + assert.deepStrictEqual(choices.map(choice => choice.operator), [...universal.slice(0, 5), ...ordered, ...universal.slice(5)]); + assert.ok(choices.every(choice => choice.label.length > 0)); + } + }); + + it('keeps absolute row identities, a real header, and excludes hidden preambles', () => { + const data = [['metadata'], ['Header'], ['keep'], ['drop'], ['KEEP']]; + const filters = [filter('equals', 'keep')]; + assert.deepStrictEqual(visibleSourceRows(data, 1, true, filters), [1, 2, 4]); + assert.deepStrictEqual(visibleSourceRows(data, 1, false, filters), [2, 4]); + assert.deepStrictEqual(visibleSourceRows(data, 100, true, filters), []); + }); + + it('retains absolute identities for typed filters across metadata, header-off, and ragged rows', () => { + const data = [['metadata'], ['ID', 'amount'], ['first', '10'], ['ragged'], ['second', '1e1'], ['third', '11'], ['invalid', 'oops']]; + const original = JSON.stringify(data); + const filters = [{ column: 1, operator: 'notEquals' as const, value: '10', dataType: 'number' as const }]; + assert.deepStrictEqual(visibleSourceRows(data, 1, true, filters), [1, 5]); + assert.deepStrictEqual(visibleSourceRows(data, 1, false, filters), [5]); + assert.strictEqual(JSON.stringify(data), original); + }); + + it('normalizes bounded structural state while retaining invalid query text to fail closed', () => { + const invalidQuery = { column: 0, operator: 'equals', value: 'invalid number', dataType: 'number' }; + const normalized = normalizeColumnFilters([ + { column: 1, operator: 'equals', value: 'before', dataType: 'boolean' }, { column: 1, operator: 'contains', value: '' }, + { column: -1, operator: 'empty', value: '' }, { column: 0, operator: 'unknown', value: 'x' }, + { column: 2, operator: 'equals', value: '1', dataType: 'unknown' }, + { column: 2, operator: 'contains', value: 'a'.repeat(MAX_FILTER_VALUE_LENGTH + 1) }, invalidQuery + ], 3); + assert.deepStrictEqual(normalized, [{ column: 1, operator: 'equals', value: 'before', dataType: 'boolean' }, { column: 1, operator: 'contains', value: '' }, invalidQuery]); + assert.deepStrictEqual(visibleSourceRows([['metadata'], ['Header'], ['invalid number'], ['1']], 1, true, normalized), [1]); + assert.strictEqual(normalizeColumnFilters(Array.from({ length: MAX_COLUMN_FILTERS + 10 }, (_, column) => ({ column, operator: 'empty', value: '' }))).length, MAX_COLUMN_FILTERS); + assert.deepStrictEqual(normalizeColumnFilters(null), []); + assert.deepStrictEqual(normalizeColumnFilters([{ column: 3, operator: 'empty', value: '' }], 3), []); + assert.ok(!compileColumnFilters(Array.from({ length: MAX_COLUMN_FILTERS + 1 }, () => filter('contains', '')))(['anything'])); + }); + + it('retains every same-column AND condition through persistence and structural remapping', () => { + const conditions: ColumnFilter[] = [ + filter('greaterThan', '2', 'number'), + { column: 1, operator: 'equals', value: 'keep' }, + filter('lessThan', '10', 'number'), + filter('notEquals', '5', 'number') + ]; + assert.deepStrictEqual(normalizeColumnFilters(conditions), conditions, 'condition order and duplicate columns survive storage'); + const source = [['Amount', 'State'], ['2', 'keep'], ['3', 'keep'], ['5', 'keep'], ['9', 'keep'], ['10', 'keep'], ['7', 'drop']]; + assert.deepStrictEqual(visibleSourceRows(source, 0, true, normalizeColumnFilters(conditions)), [0, 2, 4]); + assert.deepStrictEqual(remapColumnFilters(conditions, [-1, 1, 0]), conditions.map(item => ({ ...item, column: item.column === 0 ? 2 : 1 }))); + assert.deepStrictEqual(remapColumnFilters(conditions, [1]), [{ column: 0, operator: 'equals', value: 'keep' }]); + const withoutMiddleBound = conditions.filter((_item, index) => index !== 2); + assert.deepStrictEqual(visibleSourceRows(source, 0, true, withoutMiddleBound), [0, 2, 4, 5]); + const contradictions = [filter('contains', 'a'), filter('notContains', 'a')]; + assert.deepStrictEqual(visibleSourceRows([['Header'], ['alpha'], ['beta'], ['']], 0, true, contradictions), [0]); + }); + + it('moves and duplicates typed predicates with their columns and removes deleted ones', () => { + const filters = [filter('greaterThanOrEqual', '1,000', 'number'), { column: 2, operator: 'empty' as const, value: '' }]; + assert.deepStrictEqual(remapColumnFilters(filters, [2, -1, 0, 1, 0]), [ + { column: 2, operator: 'greaterThanOrEqual', value: '1,000', dataType: 'number' }, + { column: 4, operator: 'greaterThanOrEqual', value: '1,000', dataType: 'number' }, { column: 0, operator: 'empty', value: '' } + ]); + assert.deepStrictEqual(remapColumnFilters(filters, [1, 2]), [{ column: 1, operator: 'empty', value: '' }]); + }); +}); + +describe('Bounded RE2 column filters', () => { + it('searches case-insensitively by default and supports explicit case, multiline, and dotall flags', () => { + assert.deepStrictEqual(matchingValues(['Alpha', 'ALPHA', 'beta alpha'], filter('regex', '^alpha$')), ['Alpha', 'ALPHA']); + assert.deepStrictEqual(matchingValues(['alpha', 'ALPHA'], filter('regex', '/^alpha$/')), ['alpha']); + assert.deepStrictEqual(matchingValues(['alpha', 'ALPHA'], filter('regex', '/^alpha$/i')), ['alpha', 'ALPHA']); + assert.ok(rowMatchesFilters(['first\nsecond'], [filter('regex', '/^second$/m')])); + assert.ok(rowMatchesFilters(['a\nb'], [filter('regex', '/a.b/s')])); + assert.ok(rowMatchesFilters(['FIRST\nA\nB'], [filter('regex', '/^a.b$/ims')])); + assert.strictEqual(validateColumnFilterInput('regex', ''), undefined); + assert.ok(rowMatchesFilters([''], [filter('regex', '')])); + }); + + it('distinguishes escaped delimiters, slashes in character classes, and plain path patterns', () => { + assert.ok(rowMatchesFilters(['A/B'], [filter('regex', '/^a\\/b$/i')])); + assert.ok(rowMatchesFilters(['a/b'], [filter('regex', '/^a[/]b$/')])); + assert.ok(rowMatchesFilters(['/path'], [filter('regex', '/path')])); + assert.ok(!rowMatchesFilters(['path'], [filter('regex', '/path')])); + assert.ok(rowMatchesFilters(['a\\'], [filter('regex', '/^a\\\\$/')])); + }); + + it('rejects unsupported flags, lookarounds, backreferences, and malformed patterns with useful validation', () => { + for (const query of ['/a/g', '/a/ii', '/a/x', '(?=a)', '(a)\\1', '[']) { + assert.match(validateColumnFilterInput('regex', query) ?? '', /RE2 regular expression/); + assert.deepStrictEqual(matchingValues(['a', 'aa', 'anything', ''], filter('regex', query)), []); + } + const persisted = normalizeColumnFilters([filter('regex', '(?=a)')]); + assert.strictEqual(persisted.length, 1); + assert.deepStrictEqual(visibleSourceRows([['header'], ['a'], ['aa']], 0, true, persisted), [0]); + }); + + it('compiles patterns once per full-source pass and reuses them for every cell', () => { + const originalCompile = RE2JS.compile; + let count = 0; + RE2JS.compile = function(pattern: string, flags?: number): RE2JS { + count++; + return originalCompile.call(this, pattern, flags); + }; + try { + const rows = Array.from({ length: 2000 }, (_, index) => [`row ${index}`, index % 2 ? 'KEEP' : 'drop']); + const filters = [filter('regex', '^row [0-9]+$'), { column: 1, operator: 'regex' as const, value: '^keep$' }]; + const ids = visibleSourceRows(rows, 0, false, filters); + assert.strictEqual(count, 2); + assert.strictEqual(ids.length, 1000); + assert.strictEqual(ids[0], 1); + assert.strictEqual(ids[ids.length - 1], 1999); + const matches = compileColumnFilters(filters); + assert.strictEqual(count, 4); + assert.ok(matches(['row 1', 'KEEP'])); + assert.ok(!matches(['row 2', 'drop'])); + assert.strictEqual(count, 4); + } finally { + RE2JS.compile = originalCompile; + } + }); + + it('handles a nested repetition with a long failing cell through the linear-time engine', () => { + const matches = compileColumnFilters([filter('regex', '(a+)+$')]); + assert.ok(!matches(['a'.repeat(100000) + '!'])); + assert.ok(matches(['a'.repeat(100000)])); + }); +}); diff --git a/src/test/column-sizing.test.ts b/src/test/column-sizing.test.ts new file mode 100644 index 0000000..b119796 --- /dev/null +++ b/src/test/column-sizing.test.ts @@ -0,0 +1,114 @@ +import assert from 'assert'; +import Module from 'module'; +import vm from 'vm'; +import { describe, it } from 'node:test'; +import { createWebview } from '../test-support/sizing-webview-harness'; + +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return { window: { activeColorTheme: { kind: 1 } }, ColorThemeKind: { Dark: 1 } }; } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +describe('Column sizing behavior', () => { + it('measures longest physical line across LF, CRLF and CR without counting separators', () => { + assert.deepStrictEqual(CsvEditorProvider.__test.computeColumnWidths([ + ['short\nlongest', 'a\r\nb\r\nc', '123\r45', ''], + ['x\nx\nx\nx\nx\nx\nx\nx', 'header', '1', 'tiny'] + ]), [7, 6, 3, 4]); + }); + + it('stores validated manual widths per URI and removes only the reset file', async () => { + const store: Record = {}; + const context = { workspaceState: { + get: (key: string, fallback: any) => store[key] ?? fallback, + update: async (key: string, value: any) => { store[key] = value; } + }} as any; + const first = { toString: () => 'file:///one.csv' } as any; + const second = { toString: () => 'file:///two.tsv' } as any; + await CsvEditorProvider.setColumnSizesForUri(context, first, { '0': 180.3, '-1': 100, invalid: 200, '1': 39, '2': Infinity }); + await CsvEditorProvider.setColumnSizesForUri(context, second, { '1': 220 }); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, first), { '0': 180 }); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, second), { '1': 220 }); + await CsvEditorProvider.setColumnSizesForUri(context, first, {}); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, first), {}); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, second), { '1': 220 }); + }); + + it('restores durable widths when a fresh webview opens, including newly mounted chunks', () => { + const view = createWebview({ persistedColumns: { '0': 210 } }); + const cell = view.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(cell.style.width, '210px'); + assert.strictEqual(cell.style.boxSizing, 'border-box'); + const chunkCell = view.cell(1001, 0, 'later chunk'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(chunkCell.style.minWidth, '210px'); + assert.strictEqual(chunkCell.style.boxSizing, 'border-box'); + vm.runInContext('resetColumnWidth(0);', view.context); + assert.strictEqual(cell.style.boxSizing, ''); + assert.strictEqual(chunkCell.style.boxSizing, ''); + }); + + it('honors an explicit webview reset over durable values and restores automatic CSS', () => { + const reset = createWebview({ state: { columnSizes: {} }, persistedColumns: { '0': 210 } }); + const untouched = reset.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells();', reset.context); + assert.strictEqual(untouched.style.minWidth, '8ch'); + const view = createWebview({ persistedColumns: { '0': 210 } }); + const cell = view.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells(); resetColumnWidth(0); persistColumnSizes();', view.context); + assert.strictEqual(cell.style.width, ''); + assert.strictEqual(cell.style.minWidth, '8ch'); + assert.strictEqual(cell.style.maxWidth, '100ch'); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: {} }]); + }); + + it("restores each cell's original box sizing after repeated resizes", () => { + const view = createWebview(); + const header = view.cell(0, 0, 'header', true); + const body = view.cell(1, 0, 'body'); + header.style.boxSizing = 'content-box'; + body.style.boxSizing = 'border-box'; + vm.runInContext('applyColumnWidth(0, 140); applyColumnWidth(0, 180);', view.context); + assert.strictEqual(header.style.boxSizing, 'border-box'); + assert.strictEqual(body.style.boxSizing, 'border-box'); + vm.runInContext('resetColumnWidth(0);', view.context); + assert.strictEqual(header.style.boxSizing, 'content-box'); + assert.strictEqual(body.style.boxSizing, 'border-box'); + assert.strictEqual(header.style.minWidth, '8ch'); + assert.strictEqual(body.style.maxWidth, '100ch'); + }); + + it('starts and saves a resize from both sides of a boundary with headers on or off', () => { + for (const header of [true, false]) { + for (const side of ['left', 'right']) { + const view = createWebview({ header }); + const first = view.cell(header ? 0 : 1, 0, 'one', header); + const second = view.cell(header ? 0 : 1, 1, 'two', header); + const target = side === 'left' ? first : second; + const clientX = side === 'left' ? 97 : 103; + const clientY = header ? 15 : 45; + const event = view.dispatch(target, 'mousedown', { button: 0, clientX, clientY }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + view.dispatch(target, 'mousemove', { clientX: clientX + 50, clientY }); + view.dispatch(target, 'mouseup'); + assert.strictEqual(first.style.width, '150px'); + assert.strictEqual(second.style.width, ''); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: { '0': 150 } }]); + } + } + }); + + it('double-click resets a resized column and persists the reset', () => { + const view = createWebview(); + const header = view.cell(0, 0, 'one', true); + vm.runInContext('applyColumnWidth(0, 230);', view.context); + view.dispatch(header, 'dblclick', { clientX: 96, clientY: 15 }); + assert.strictEqual(header.style.minWidth, '8ch'); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: {} }]); + }); +}); diff --git a/src/test/context-menu-positioning.test.ts b/src/test/context-menu-positioning.test.ts new file mode 100644 index 0000000..5ca10c1 --- /dev/null +++ b/src/test/context-menu-positioning.test.ts @@ -0,0 +1,211 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +async function fixture(separator = ',') { + const host = await createEditorHarness({ rows: [['Name', 'Value'], ['one', 'two']], header: true, separator }); + const view = createWebviewDom(host.panel.webview.html); + const { window } = view; + const menu = window.document.getElementById('contextMenu'); + let naturalSize: { width: number; height: number } | undefined; + const measurements: Array<{ left: string; top: string; display: string }> = []; + const baseFontSize = Number.parseFloat(window.getComputedStyle(window.document.body).fontSize); + const natural = () => { + if (naturalSize) { return naturalSize; } + const scale = Number.parseFloat(window.getComputedStyle(window.document.body).fontSize) / baseFontSize; + return { + width: Math.max(...Array.from(menu.children).map((item: any) => item.textContent.length * 8 * scale + 26)), + height: Array.from(menu.children).reduce((sum: number, item: any) => sum + (item.textContent ? 24 * scale : 4), 2) + }; + }; + const size = () => ({ + width: Math.min(natural().width, Number.parseFloat(menu.style.maxWidth)), + height: Math.min(natural().height, Number.parseFloat(menu.style.maxHeight)) + }); + menu.getBoundingClientRect = () => { + measurements.push({ left: menu.style.left, top: menu.style.top, display: menu.style.display }); + const { width, height } = size(); + const left = Number.parseFloat(menu.style.left), top = Number.parseFloat(menu.style.top); + return { left, top, right: left + width, bottom: top + height, width, height }; + }; + Object.defineProperty(menu, 'scrollHeight', { get: () => natural().height }); + Object.defineProperty(menu, 'clientHeight', { get: () => size().height }); + const viewport = (width: number, height: number) => { + Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: height, configurable: true }); + }; + viewport(1000, 700); + const open = (x: number, y: number, row = 1, column = 0, pageOffsetX = 0, pageOffsetY = 0) => { + const target = view.cell(row, column); + target.dispatchEvent(new window.MouseEvent('mousedown', { button: 2, bubbles: true, cancelable: true, clientX: x, clientY: y })); + const event = new window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }); + Object.defineProperty(event, 'pageX', { value: x + pageOffsetX }); + Object.defineProperty(event, 'pageY', { value: y + pageOffsetY }); + target.dispatchEvent(event); + assert.strictEqual(event.defaultPrevented, true); + return { left: Number.parseFloat(menu.style.left), top: Number.parseFloat(menu.style.top), ...size() }; + }; + return { ...view, menu, measurements, viewport, open, setSize: (width: number, height: number) => { naturalSize = { width, height }; } }; +} + +describe('Context menu viewport positioning', () => { + it('renders the provider popup at natural width with bounded wrapping and scrolling', async () => { + const view = await fixture(); + try { + const css = view.window.getComputedStyle(view.menu); + assert.strictEqual(css.position, 'fixed'); + assert.strictEqual(css.width, 'max-content'); + assert.strictEqual(css.boxSizing, 'border-box'); + assert.strictEqual(css.overflow, 'auto'); + assert.strictEqual(css.overflowWrap, 'anywhere'); + } finally { view.close(); } + }); + + for (const separator of [',', '\t']) { + it(`flips at the measured right-space threshold, including fractional coordinates (${JSON.stringify(separator)})`, async () => { + const view = await fixture(separator); + try { + view.setSize(200, 120); + assert.strictEqual(view.open(792, 100).left, 792, 'exact fit stays on the right, beyond the screen midpoint'); + assert.strictEqual(view.open(792.5, 100).left, 592.5, 'half a pixel beyond the measured threshold opens left'); + assert.ok(view.measurements.every(measurement => measurement.left === '8px' && measurement.top === '8px' && measurement.display === 'block')); + } finally { view.close(); } + }); + } + + it('chooses a side again for differently sized menus at the same pointer position', async () => { + const view = await fixture(); + try { + view.setSize(100, 120); + assert.strictEqual(view.open(650, 100).left, 650); + view.setSize(400, 120); + assert.strictEqual(view.open(650, 100).left, 250); + } finally { view.close(); } + }); + + it('measures the completed header menu, including its extra actions', async () => { + const view = await fixture(); + try { + const body = view.open(100, 100, 1); + const x = 1000 - 8 - body.width; + const y = 700 - 8 - body.height; + const fittedBody = view.open(x, y, 1); + assert.strictEqual(fittedBody.left, x); + assert.strictEqual(fittedBody.top, y); + const header = view.open(x, y, 0); + assert.ok(header.width >= body.width); + assert.ok(header.height > body.height); + assert.strictEqual(header.left, header.width > body.width ? x - header.width : x); + assert.strictEqual(header.top, Math.max(8, y - header.height)); + assert.ok(Array.from(view.menu.children).some((item: any) => item.textContent === 'Treat first row as data')); + assert.ok(Array.from(view.menu.children).some((item: any) => item.textContent === 'Sort: A-Z')); + } finally { view.close(); } + }); + + it('flips upward at the measured bottom-space threshold', async () => { + const view = await fixture(); + try { + view.setSize(200, 120); + assert.strictEqual(view.open(100, 572).top, 572); + assert.strictEqual(view.open(100, 572.5).top, 452.5); + } finally { view.close(); } + }); + + it('clamps all four corners inside the viewport margins', async () => { + const view = await fixture(); + try { + view.setSize(200, 120); + for (const [x, y, left, top] of [[0, 0, 8, 8], [1000, 0, 792, 8], [0, 700, 8, 572], [1000, 700, 792, 572]]) { + const placed = view.open(x, y); + assert.strictEqual(placed.left, left); + assert.strictEqual(placed.top, top); + assert.ok(placed.left + placed.width <= 992); + assert.ok(placed.top + placed.height <= 692); + } + } finally { view.close(); } + }); + + it('uses viewport client coordinates independently of document and table scrolling', async () => { + const view = await fixture(); + try { + view.setSize(200, 120); + Object.defineProperty(view.window, 'scrollX', { value: 5000 }); + Object.defineProperty(view.window, 'scrollY', { value: 7000 }); + const container = view.window.document.querySelector('.table-container'); + container.scrollLeft = 300; + container.scrollTop = 600; + const placed = view.open(100, 200, 1, 0, 5000, 7000); + assert.strictEqual(placed.left, 100); + assert.strictEqual(placed.top, 200); + assert.strictEqual(container.scrollLeft, 300); + assert.strictEqual(container.scrollTop, 600); + } finally { view.close(); } + }); + + it('bounds tiny editor menus and retains reachable actions through vertical scrolling', async () => { + const view = await fixture(); + try { + view.setSize(300, 500); + for (const [width, height] of [[120, 90], [16, 12]]) { + view.viewport(width, height); + const placed = view.open(width - 1, height - 1); + assert.ok(placed.width <= width - 2 * Math.min(8, width / 4)); + assert.ok(placed.height <= height - 2 * Math.min(8, height / 4)); + assert.ok(placed.left >= 0 && placed.left + placed.width <= width); + assert.ok(placed.top >= 0 && placed.top + placed.height <= height); + assert.ok(view.menu.scrollHeight > view.menu.clientHeight); + } + view.menu.scrollTop = view.menu.scrollHeight - view.menu.clientHeight; + const last = view.menu.lastElementChild; + assert.strictEqual(last.textContent, 'Delete COLUMN'); + last.click(); + assert.strictEqual(view.messages.at(-1).type, 'deleteColumn'); + assert.strictEqual(view.messages.at(-1).index, 0); + assert.strictEqual(view.menu.style.display, 'none'); + } finally { view.close(); } + }); + + it('dismisses only when CSV zoom changes and measures the enlarged menu on reopen', async () => { + const view = await fixture(); + try { + const original = view.open(100, 100); + const x = 1000 - 8 - original.width; + assert.strictEqual(view.open(x, 100).left, x); + view.key('0', { ctrlKey: true }); + assert.strictEqual(view.menu.style.display, 'block', 'reset at the existing zoom leaves the menu open'); + const originalFontSize = Number.parseFloat(view.window.getComputedStyle(view.window.document.body).fontSize); + view.key('+', { ctrlKey: true }); + assert.strictEqual(view.menu.style.display, 'none'); + assert.ok(Number.parseFloat(view.window.document.body.style.fontSize) > originalFontSize); + const enlarged = view.open(x, 100); + assert.ok(enlarged.width > original.width); + assert.strictEqual(enlarged.left, x - enlarged.width); + assert.ok(enlarged.left + enlarged.width <= 992); + assert.strictEqual(view.menu.style.display, 'block'); + } finally { view.close(); } + }); + + it('dismisses on viewport resize and remeasures/reset scroll when reopened', async () => { + const view = await fixture(); + try { + view.viewport(120, 90); + view.setSize(300, 500); + view.open(119, 89); + view.menu.scrollTop = 200; + view.menu.scrollLeft = 20; + view.viewport(1000, 700); + view.window.dispatchEvent(new view.window.Event('resize')); + assert.strictEqual(view.menu.style.display, 'none'); + view.setSize(180, 100); + const placed = view.open(100, 100); + assert.strictEqual(placed.left, 100); + assert.strictEqual(placed.top, 100); + assert.strictEqual(placed.width, 180); + assert.strictEqual(placed.height, 100); + assert.strictEqual(view.menu.scrollTop, 0); + assert.strictEqual(view.menu.scrollLeft, 0); + assert.strictEqual(view.menu.style.display, 'block'); + assert.ok(Array.from(view.menu.children).some((item: any) => item.textContent === 'Copy')); + } finally { view.close(); } + }); +}); diff --git a/src/test/csv-table-diff.test.ts b/src/test/csv-table-diff.test.ts new file mode 100644 index 0000000..9a7c93c --- /dev/null +++ b/src/test/csv-table-diff.test.ts @@ -0,0 +1,97 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { compareCsvRows, comparisonLimits, comparisonPages, parseComparisonRows } from '../csv-table-diff'; + +describe('CSV table difference model', () => { + it('aligns inserted/deleted records and isolates changed fields around multiline values', () => { + const original = parseComparisonRows('id,value\r\n1,"one\r\ntwo"\r\n2,old\r\n3,remove\r\n4,same\r\n', ','); + const modified = parseComparisonRows('id,value\n0,added\n1,"one\r\ntwo"\n2,new\n4,same\n', ','); + const diff = compareCsvRows(original, modified); + assert.deepStrictEqual(diff.records.map(record => [record.original, record.modified, record.kind]), [ + [0, 0, 'equal'], [undefined, 1, 'added'], [1, 2, 'equal'], [2, 3, 'changed'], [3, undefined, 'removed'], [4, 4, 'equal'] + ]); + assert.deepStrictEqual(diff.records[3].changedColumns, [1]); + assert.deepStrictEqual([diff.added, diff.removed, diff.changed, diff.approximate], [1, 1, 1, false]); + }); + + it('aligns a modified record with shared fields after an adjacent deletion', () => { + const diff = compareCsvRows([['gone', 'old'], ['id', 'before']], [['id', 'after']]); + assert.deepStrictEqual(diff.records.map(record => [record.original, record.modified, record.kind]), [ + [0, undefined, 'removed'], [1, 0, 'changed'] + ]); + assert.deepStrictEqual(diff.records[1].changedColumns, [1]); + }); + + it('distinguishes a missing field from an empty field, without counting quote/terminator style', () => { + const diff = compareCsvRows(parseComparisonRows('"a",b\r\nx\r\n', ','), parseComparisonRows('a,b\nx,\n', ',')); + assert.strictEqual(diff.records[0].kind, 'equal'); + assert.deepStrictEqual(diff.records[1].changedColumns, [1]); + assert.strictEqual(diff.changed, 1); + assert.deepStrictEqual(parseComparisonRows('a\n\n', ','), [['a'], ['']]); + }); + + for (const delimiter of [',', '\t', '|']) { + it(`parses ${JSON.stringify(delimiter)} fields with doubled quotes, BOM and multiline Unicode`, () => { + const rows = parseComparisonRows(`\ufeffname${delimiter}value\r\n"é\n🙂"${delimiter}"a""b"\r\n`, delimiter); + assert.deepStrictEqual(rows, [['name', 'value'], ['é\n🙂', 'a"b']]); + }); + } + + it('preserves every source record in order with duplicates and empty sides', () => { + const inputs = [[], [['']], [['a'], ['a'], ['b']], [['a'], ['b'], ['a']], [['x', ''], ['y']]]; + for (const original of inputs) { + for (const modified of inputs) { + const diff = compareCsvRows(original, modified); + assert.deepStrictEqual(diff.records.flatMap(record => record.original === undefined ? [] : [record.original]), original.map((_, index) => index)); + assert.deepStrictEqual(diff.records.flatMap(record => record.modified === undefined ? [] : [record.modified]), modified.map((_, index) => index)); + } + } + }); + + it('bounds large alignment work and explicitly reports positional sections', () => { + const original = Array.from({ length: comparisonLimits.rows }, (_, index) => [`old-${index}`]); + const modified = Array.from({ length: comparisonLimits.rows }, (_, index) => [`new-${index}`]); + const diff = compareCsvRows(original, modified); + assert.strictEqual(diff.approximate, true); + assert.strictEqual(diff.records.length, comparisonLimits.rows); + assert.strictEqual(diff.changed, comparisonLimits.rows); + assert.deepStrictEqual(diff.records.at(-1)?.changedColumns, [0]); + }); + + it('uses unique unchanged anchors on large inputs with additions', () => { + const original = Array.from({ length: 1500 }, (_, index) => [String(index)]); + const modified = [['first'], ...original.map(row => [...row]), ['last']]; + modified[750] = ['changed']; + const diff = compareCsvRows(original, modified); + assert.strictEqual(diff.added, 2); + assert.strictEqual(diff.changed, 1); + assert.strictEqual(diff.removed, 0); + assert.strictEqual(diff.records.find(record => record.original === 1499)?.modified, 1500); + }); + + it('rejects malformed and oversized values, records, rows, cells and decoded input without truncation', () => { + assert.throws(() => parseComparisonRows('"unfinished', ','), /malformed/); + assert.throws(() => parseComparisonRows('a'.repeat(comparisonLimits.cellChars + 1), ','), /display limit/); + assert.throws(() => parseComparisonRows(Array(6).fill('x'.repeat(50000)).join(','), ','), /display limit/); + assert.throws(() => parseComparisonRows(Array(comparisonLimits.rows + 1).fill('x').join('\n'), ','), /10,000/); + assert.throws(() => parseComparisonRows(Array(1001).fill('x').join(','), ','), /1,000/); + assert.throws(() => parseComparisonRows(Array(101).fill(Array(1000).fill('x').join(',')).join('\n'), ','), /100,000/); + assert.throws(() => parseComparisonRows('é'.repeat(comparisonLimits.textBytes / 2 + 1), ','), /5 MiB/); + }); + + it('pages within row, rendered body-cell and text bounds', () => { + for (const rows of [Array.from({ length: 501 }, () => ['a']), Array.from({ length: 30 }, () => Array(1000).fill('x')), Array.from({ length: 30 }, () => ['x'.repeat(60000)])]) { + const diff = compareCsvRows(rows, rows); + const pages = comparisonPages(diff); + assert.ok(pages.length > 1); + assert.strictEqual(pages[0].start, 0); + assert.strictEqual(pages.at(-1)?.end, rows.length); + for (const page of pages) { + const slice = rows.slice(page.start, page.end); + assert.ok(slice.length <= comparisonLimits.pageRows); + assert.ok(slice.reduce((sum, row) => sum + row.length * 2 + 2, 0) <= comparisonLimits.pageCells); + assert.ok(slice.reduce((sum, row) => sum + row.join('').length * 2, 0) <= comparisonLimits.pageChars); + } + } + }); +}); diff --git a/src/test/default-ignore-rows-commands.test.ts b/src/test/default-ignore-rows-commands.test.ts new file mode 100644 index 0000000..220409a --- /dev/null +++ b/src/test/default-ignore-rows-commands.test.ts @@ -0,0 +1,61 @@ +import assert from 'assert'; +import { createTestMemento } from './test-memento'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +const commands = new Map Promise>(); +let input: string | undefined; +let prompt: any; +const routingErrors: string[] = []; +const configurationListeners: Array<(event: any) => Promise> = []; +let changedConfiguration: (event: any) => Promise; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + TabInputText: class {}, + ConfigurationTarget: { Global: 1, Workspace: 2 }, + commands: { registerCommand: (name: string, callback: () => Promise) => { commands.set(name, callback); return { dispose() {} }; } }, + window: { tabGroups: { all: [], activeTabGroup: { viewColumn: 1, tabs: [], activeTab: undefined } }, visibleTextEditors: [], onDidChangeActiveTextEditor: () => ({ dispose() {} }), showInputBox: async (options: any) => { prompt = options; return input; }, showInformationMessage() {}, showErrorMessage: (message: string) => { routingErrors.push(message); }, registerCustomEditorProvider: () => ({ dispose() {} }) }, + workspace: { textDocuments: [], onDidOpenTextDocument: () => ({ dispose() {} }), onDidCloseTextDocument: () => ({ dispose() {} }), getConfiguration: (section: string) => ({ inspect: (key: string) => section === 'csv' && key === 'enabled' ? { defaultValue: true } : undefined, get: (key: string, fallback: any) => section === 'csv' && key === 'defaultIgnoreRows' ? 7 : fallback }), onDidChangeConfiguration: (listener: any) => { configurationListeners.push(listener); changedConfiguration = event => Promise.all(configurationListeners.map(callback => callback(event))); return { dispose() {} }; } } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { activate } from '../extension'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +describe('Hidden-row override controls', () => { + it('uses zero as an explicit override and blank input to inherit again; cancellation keeps the override', async () => { + const values: Record = {}; + const context = { subscriptions: [], globalState: createTestMemento(), workspaceState: { keys: () => Object.keys(values).filter(key => values[key] !== undefined), get: (key: string, fallback: any) => values[key] ?? fallback, update: async (key: string, value: any) => { values[key] = value; } } } as any; + await activate(context); + assert.deepStrictEqual(routingErrors, [], 'routing initialization must finish without fixture errors'); + const uri = { toString: () => 'file:///sample.csv' } as any; + let refreshed = 0; + const active = { isActive: () => true, getDocumentUri: () => uri, refreshViewSafely: () => refreshed++ } as any; + CsvEditorProvider.currentActive = active; + CsvEditorProvider.editors = [active]; + input = '0'; + await commands.get('csv.changeIgnoreRows')!(); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 0); + await changedConfiguration!({ affectsConfiguration: (key: string) => key === 'csv.defaultIgnoreRows' }); + assert.strictEqual(refreshed, 1, 'an explicit override must not interrupt editing when defaults change'); + assert.strictEqual(prompt.validateInput(''), undefined); + assert.ok(prompt.validateInput('-1')); + assert.ok(prompt.validateInput('1.5')); + input = undefined; + await commands.get('csv.changeIgnoreRows')!(); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 0); + input = ''; + await commands.get('csv.changeIgnoreRows')!(); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 7); + assert.strictEqual(refreshed, 2); + await changedConfiguration!({ affectsConfiguration: (key: string) => key === 'csv.defaultIgnoreRows' }); + assert.strictEqual(refreshed, 3, 'default setting updates use the edit-safe refresh path'); + assert.deepStrictEqual(routingErrors, []); + CsvEditorProvider.currentActive = undefined; + CsvEditorProvider.editors = []; + }); +}); diff --git a/src/test/default-ignore-rows.test.ts b/src/test/default-ignore-rows.test.ts new file mode 100644 index 0000000..4a0da63 --- /dev/null +++ b/src/test/default-ignore-rows.test.ts @@ -0,0 +1,76 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); + +describe('Default hidden rows', () => { + it('inherits resource-specific defaults while explicit per-file zero wins until reset', async () => { + const host = await createEditorHarness({ defaultIgnoreRows: 1 }); + const uri = host.document.uri as any; + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 1); + assert.ok(host.configurationRequests.includes(uri)); + const other = { toString: () => 'file:///other.csv' } as any; + host.resourceDefaults[other.toString()] = 3; + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, other), 3); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, 0); + host.defaultIgnoreRows = 2; + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 0); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, 4); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 4); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, undefined); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 2); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, other), 3); + }); + + it('hides inherited metadata from the view while preserving source rows and frozen-row identities', async () => { + const rows = [['metadata', 'preserve'], ['Name', 'Value'], ['one', 'two']]; + const host = await createEditorHarness({ rows, defaultIgnoreRows: 1, frozenRows: 2 }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.cell(0, 0), null); + assert.strictEqual(view.cell(1, 0).tagName, 'TH'); + assert.strictEqual(view.cell(2, 0).textContent, 'one'); + view.window.csvFrozenPanes.layout(); + assert.ok(view.cell(1, 0).classList.contains('csv-frozen-row')); + assert.ok(view.cell(2, 0).classList.contains('csv-frozen-row')); + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + }); + + it('commits an active multiline draft before refreshing an inherited default', async () => { + const host = await createEditorHarness({ rows: [['metadata', 'keep'], ['Name', 'Value'], ['one', 'two'], ['three', 'four']], defaultIgnoreRows: 1 }); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(2, 0); + view.start(cell); + cell.textContent = 'unsaved\nmultiline draft'; + host.defaultIgnoreRows = 2; + CsvEditorProvider.currentActive!.refreshViewSafely(); + const prepare = host.messages.slice().reverse().find((message: any) => message.type === 'prepareViewRefresh'); + assert.ok(prepare); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare })); + const messages = view.messages.filter((message: any) => ['editCell', 'viewRefreshReady'].includes(message.type)); + assert.deepStrictEqual(messages.map((message: any) => message.type), ['editCell', 'viewRefreshReady']); + for (const message of messages) { await host.receive(message); } + assert.strictEqual(host.rows()[2][0], 'unsaved\nmultiline draft'); + assert.deepStrictEqual(host.rows()[0], ['metadata', 'keep']); + assert.ok(host.panel.webview.html.includes('data-firstrow="2"')); + assert.ok(host.panel.webview.html.includes('unsaved')); + } finally { view.close(); } + }); + + it('declares one resource-scoped integer setting and documents explicit zero/inherit reset', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const setting = manifest.contributes.configuration.properties['csv.defaultIgnoreRows']; + assert.strictEqual(setting.type, 'integer'); + 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`')); + }); +}); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts new file mode 100644 index 0000000..6da5ede --- /dev/null +++ b/src/test/edit-integrity-harness.ts @@ -0,0 +1,406 @@ +import { TestExtensionUri } from './test-extension-uri'; +import { createTestMemento } from './test-memento'; +import fs from 'fs'; +import path from 'path'; +import Module from 'module'; +import Papa from 'papaparse'; + +class TestUri { + constructor(public readonly path: string, public readonly scheme = 'file') {} + get fsPath() { return this.path; } + toString() { return `${this.scheme}://${this.path}`; } + static file(filePath: string) { return new TestUri(filePath); } +} + +let activeHarness: any; +const vscodeStub = { + Uri: TestUri, + TabInputText: class { constructor(readonly uri: TestUri) {} }, + ConfigurationTarget: { Global: 1, Workspace: 2 }, + ViewColumn: { Active: -1 }, + Range: class {}, + WorkspaceEdit: class { + text = ''; + replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } + }, + ColorThemeKind: { Dark: 1 }, + window: { + activeColorTheme: { kind: 1 }, + onDidChangeActiveTextEditor: () => ({ dispose() {} }), + showTextDocument: async (document: any) => { + activeHarness.sourceReveals.push(document.getText()); + return { document }; + }, + showWarningMessage: async (message: string) => { + activeHarness.warnings.push(message); + return activeHarness.warningChoice; + }, + showQuickPick: async (items: any[], options: any) => { + activeHarness.quickPickCalls.push({ items, options }); + return typeof activeHarness.filterChoice === 'function' ? activeHarness.filterChoice(items, options) : activeHarness.filterChoice; + }, + showInputBox: async (options: any) => { + activeHarness.inputBoxCalls.push(options); + return activeHarness.filterInput; + }, + showInformationMessage: (text: string) => activeHarness.notifications.push(text), + showErrorMessage: async (text: string) => { activeHarness.notifications.push(text); activeHarness.errors.push(text); }, + showOpenDialog: async () => activeHarness.dialogResults.shift(), + get visibleTextEditors() { return activeHarness.visibleTextEditors; }, + get activeTextEditor() { return activeHarness.activeTextDocument ? { document: activeHarness.activeTextDocument } : undefined; }, + createWebviewPanel: (...args: unknown[]) => activeHarness.createPanel(...args), + tabGroups: { + get activeTabGroup() { return this.all[0]; }, + get all() { return activeHarness.tabGroups ?? [{ tabs: [{ input: { uri: activeHarness.document.uri, viewType: 'csv.editor' } }] }]; }, + close: async () => activeHarness.closeResult + }, + registerCustomEditorProvider: (viewType: string) => { + activeHarness.providers.push(viewType); + return { dispose() {} }; + } + }, + commands: { + registerCommand: (name: string, command: (...args: any[]) => unknown) => { + activeHarness.commands.set(name, command); + return { dispose() {} }; + }, + executeCommand: async (name: string, ...args: unknown[]) => { + activeHarness.executedCommands.push({ name, args }); + activeHarness.recordedCommands.push([name, ...args]); + if (activeHarness.commandError) { + throw activeHarness.commandError; + } + if (name === 'workbench.action.editor.changeEncoding') { + await activeHarness.commandWait; + } + if (name === 'workbench.action.files.save') { + activeHarness.nativeSaveCommands.push(name); + return activeHarness.document.save(); + } + } + }, + env: { clipboard: { writeText: async (text: string) => { activeHarness.clipboardText = text; }, readText: async () => activeHarness.clipboardText } }, + workspace: { + onDidOpenTextDocument: () => ({ dispose() {} }), + onDidCloseTextDocument: () => ({ dispose() {} }), + getConfiguration: (section: string, resource?: TestUri) => { + if (activeHarness) { activeHarness.configurationRequests.push(resource); } + return { inspect: (key: string) => section === 'csv' && key === 'enabled' ? { defaultValue: true, globalValue: activeHarness.configuration.enabled ?? activeHarness.enabled } : undefined, + get: (key: string, fallback: unknown) => { + if (key in activeHarness.configuration) { return activeHarness.configuration[key]; } + if (key === 'enabled') { return activeHarness?.enabled ?? true; } + if (key === 'maxFileSizeMB') { return 0; } + if (key === 'defaultIgnoreRows') { return activeHarness.resourceDefaults[resource?.toString() ?? ''] ?? activeHarness.defaultIgnoreRows; } + return fallback; + } }; + }, + fs: { + stat: async (uri: TestUri) => { + if (activeHarness.statError) { throw activeHarness.statError; } + return { size: activeHarness.statSize ?? Buffer.byteLength(activeHarness.sourceDocuments.get(uri.toString())?.getText() ?? '') }; + } + }, + get textDocuments() { return activeHarness.existingDocuments; }, + openTextDocument: async (uri: TestUri) => { + activeHarness.openedDocuments.push(uri.toString()); + const document = activeHarness.sourceDocuments.get(uri.toString()); + if (!document) { throw new Error('Unknown test document'); } + return document; + }, + applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), + onDidChangeTextDocument: (listener: (event: unknown) => void) => { + activeHarness.changeListeners.push(listener); + return { dispose() {} }; + }, + onDidChangeConfiguration: (listener: (event: any) => void) => { + activeHarness.configurationListeners.push(listener); + return { dispose() {} }; + }, + onWillSaveTextDocument: (listener: (event: unknown) => void) => { + activeHarness.saveListeners.push(listener); + return { dispose() {} }; + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return vscodeStub; + } + return originalRequire.apply(this, arguments as any); +}; +// Load the provider only after installing the VS Code API stub. +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); +const { JSDOM } = require('jsdom'); + +export async function createEditorHarness(options: { + text?: string; + warningChoice?: string | Promise; + rows?: string[][]; + separator?: string; + header?: boolean; + serialIndex?: boolean; + remote?: boolean; + frozenRows?: number; + frozenColumns?: number; + hiddenRows?: number; + defaultIgnoreRows?: number; + filters?: unknown; + untitled?: boolean; + fileName?: string; + separatorOverride?: string; + cancelInitialization?: boolean; + closeDuringInitialization?: boolean; +} = {}) { + const separator = options.separator ?? ','; + let text = options.text ?? Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); + let diskText = text; + let messageListener: (message: any) => Promise; + const changeListeners: Array<(event: unknown) => void> = []; + const viewListeners: Array<(event: unknown) => void> = []; + const saveListeners: Array<(event: unknown) => void> = []; + const disposeListeners: Array<() => void> = []; + let controller: any; + const messages: any[] = []; + const delayedEdits: Array<() => void> = []; + let deferEdits = false; + let editFailure: 'false' | 'throw' | undefined; + const uri = new TestUri(options.fileName ?? `/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.untitled ? 'untitled' : options.remote ? 'vscode-remote' : 'file'); + const document = { + uri, + isClosed: false, + languageId: separator === '\t' ? 'tsv' : 'csv', + version: 1, + getText: () => text, + get lineCount() { return text.split(/\r\n|\n|\r/).length; }, + lineAt: (index: number) => ({ text: text.split(/\r\n|\n|\r/)[index] ?? '' }), + save: async () => { + const pending: Promise[] = []; + saveListeners.forEach(listener => listener({ document, waitUntil: (promise: Promise) => pending.push(promise) })); + await Promise.all(pending); + diskText = text; + return true; + } + }; + const context = { + subscriptions: [] as unknown[], + extensionPath: process.cwd(), extensionUri: new TestExtensionUri(), + globalState: createTestMemento(), + workspaceState: { + keys() { return Object.keys(this.values).filter(key => this.values[key] !== undefined); }, + values: {} as Record, + get(key: string, fallback: unknown) { return this.values[key] ?? fallback; }, + async update(key: string, value: unknown) { this.values[key] = value; } + } + }; + await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + await CsvEditorProvider.setSerialIndexForUri(context as any, uri as any, options.serialIndex ?? true); + if (options.separatorOverride) { + await CsvEditorProvider.setSeparatorForUri(context as any, uri as any, options.separatorOverride); + } + await CsvEditorProvider.setColumnFiltersForUri(context as any, uri as any, options.filters); + if (options.hiddenRows !== undefined) { + await CsvEditorProvider.setHiddenRowsForUri(context as any, uri as any, options.hiddenRows); + } + await CsvEditorProvider.setFrozenPanesForUri(context as any, uri as any, { rows: options.frozenRows, columns: options.frozenColumns }); + const panel = { + active: true, + viewColumn: 2, + visible: true, + title: 'data.csv', + webview: { + html: '', + options: {}, + cspSource: 'test:', + asWebviewUri: (resource: TestUri) => resource.toString(), + postMessage: (message: unknown) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } + }, + onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, + disposed: false, + onDidDispose(listener: () => void) { disposeListeners.push(listener); }, + dispose() { + panel.disposed = true; + panel.active = false; + panel.visible = false; + disposeListeners.forEach(listener => listener()); + } + }; + const harness = { + document, panel, messages, changeListeners, saveListeners, + configuration: {} as Record, + configurationListeners: [] as Array<(event: any) => void>, + providers: [] as string[], + tabGroups: undefined as any[] | undefined, + errors: [] as string[], + dialogResults: [] as Array, + activeTextDocument: undefined as any, + visibleTextEditors: [] as any[], + createPanel: undefined as any, + openedDocuments: [] as string[], + sourceDocuments: new Map(), + existingDocuments: [] as any[], + statError: undefined as Error | undefined, + statSize: undefined as number | undefined, + warnings: [] as string[], + warningChoice: options.warningChoice, + commandError: undefined as Error | undefined, + commandWait: undefined as Promise | undefined, + dispose() { panel.dispose(); }, + closeResult: true, + commands: new Map unknown>(), + executedCommands: [] as Array<{ name: string; args: unknown[] }>, + registerCommands() { + const { registerCsvCommands } = require('../commands') as typeof import('../commands'); + registerCsvCommands(context as any); + }, + context, + defaultIgnoreRows: options.defaultIgnoreRows ?? 0, + clipboardText: '', + filterChoice: undefined as any, + filterInput: undefined as string | undefined, + quickPickCalls: [] as Array<{ items: any[]; options: any }>, + inputBoxCalls: [] as any[], + notifications: [] as string[], + resourceDefaults: {} as Record, + configurationRequests: [] as Array, + enabled: true, + recordedCommands: [] as unknown[][], + refresh() { CsvEditorProvider.editors.find(controller => controller.getDocumentUri() === uri as any)!.refresh(); }, + get isOpen() { return CsvEditorProvider.editors.some(controller => controller.getDocumentUri() === uri as any); }, + async setSeparator(separator: string) { await CsvEditorProvider.setSeparatorForUri(context as any, uri as any, separator); }, + sourceReveals: [] as string[], + nativeSaveCommands: [] as string[], + get controllerCount() { return CsvEditorProvider.editors.length; }, + get activeController() { return CsvEditorProvider.currentActive; }, + saveUntitledAfterCommit() { controller.saveUntitledAfterCommit(); }, + requestViewCommit() { controller.requestViewCommit(); }, + commitPendingDraft() { return controller.commitPendingDraft() as Promise; }, + revealSourceAfterCommit() { harness.enabled = false; controller.revealSourceAfterCommit(); }, + setEnabled(enabled: boolean) { harness.enabled = enabled; controller.refresh(); }, + getCurrentSeparator() { return controller.getCurrentSeparator() as string; }, + rebindDocument(languageId: string) { + const replacement = { + ...document, languageId, + get version() { return document.version; }, + getText: () => text, + get lineCount() { return text.split(/\r\n|\n|\r/).length; } + }; + controller.updateDocumentReference(replacement); + return replacement; + }, + get diskText() { return diskText; }, + get pendingEdits() { return delayedEdits.length; }, + get pendingDraftRequestId(): number | undefined { return controller?.viewCommitRequested ? controller.viewCommitSequence : undefined; }, + deferEdits() { deferEdits = true; }, + resumeEdits() { deferEdits = false; }, + failEdits(mode: 'false' | 'throw' | undefined) { editFailure = mode; }, + releaseEdit() { delayedEdits.shift()?.(); }, + async applyEdit(nextText: string): Promise { + if (editFailure === 'false') { return false; } + if (editFailure === 'throw') { throw new Error('applyEdit rejected'); } + return new Promise(resolve => { + const apply = () => { + text = nextText; + document.version += 1; + changeListeners.forEach(listener => listener({ document })); + resolve(true); + }; + if (deferEdits) { + delayedEdits.push(apply); + } else { + apply(); + } + }); + }, + receive(message: unknown) { return messageListener(message); }, + async view(active: boolean, visible: boolean) { + panel.active = active; + panel.visible = visible; + viewListeners.forEach(listener => listener({ webviewPanel: panel })); + await messageListener({ type: 'test-drain' }); + }, + rows() { return Papa.parse(text, { delimiter: separator }).data; } + }; + activeHarness = harness; + const token = { isCancellationRequested: false }; + const initializing = new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, token as any); + if (options.cancelInitialization || options.closeDuringInitialization) { + token.isCancellationRequested = !!options.cancelInitialization; + document.isClosed = !!options.closeDuringInitialization; + panel.dispose(); + } + await initializing; + controller = CsvEditorProvider.currentActive; + return harness; +} + +export function createWebviewDom(html: string) { + const dom = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const messages: any[] = []; + let state: unknown = {}; + window.acquireVsCodeApi = () => ({ + postMessage: (message: unknown) => messages.push(message), + getState: () => state, + setState: (nextState: unknown) => { state = nextState; } + }); + window.HTMLElement.prototype.scrollIntoView = () => {}; + // JSDOM has no paint hit testing. Interaction tests may supply cell geometry; + // use those rectangles unless a scenario installs its own occlusion model. + window.document.elementFromPoint = (x: number, y: number) => + Array.from(window.document.querySelectorAll('td, th')).reverse().find((cell: any) => { + const rect = cell.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }) ?? null; + window.IntersectionObserver = class { observe() {} disconnect() {} }; + const source = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'); + const freezeSource = fs.readFileSync(path.join(process.cwd(), 'media', 'frozen-panes.js'), 'utf8'); + window.eval(freezeSource); + window.eval(fs.readFileSync(path.join(process.cwd(), 'media', 'column-filters.js'), 'utf8')); + window.eval(`${source}\nwindow.editingTestApi = { editCell, setSingleSelection, selectRange, selectFullRowRange };`); + return { + window, messages, + state: () => state, + close() { window.close(); }, + cell(row = 1, col = 0) { + return window.document.querySelector(`[data-row="${row}"][data-col="${col}"]`); + }, + start(cell: any) { window.editingTestApi.setSingleSelection(cell); window.editingTestApi.editCell(cell); }, + key(key: string, options: Record = {}) { + window.document.activeElement.dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options })); + }, + type(value: string) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + range.deleteContents(); + const node = window.document.createTextNode(value); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }, + caret(node: any, offset: number) { + const range = window.document.createRange(); + range.setStart(node, offset); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + }; +} + +export async function flushViewCommits(host: Awaited>, view: ReturnType): Promise { + for (let round = 0; round < 10; round++) { + const requestId = host.pendingDraftRequestId; + if (requestId === undefined) { return; } + const request = host.messages.find(message => message.type === 'prepareViewRefresh' && message.requestId === requestId); + if (!request) { throw new Error('Missing prepare message'); } + const cursor = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + await Promise.all(view.messages.slice(cursor).map(message => host.receive(message))); + } + throw new Error('View commit did not settle'); +} diff --git a/src/test/edit-integrity.test.ts b/src/test/edit-integrity.test.ts new file mode 100644 index 0000000..755c07f --- /dev/null +++ b/src/test/edit-integrity.test.ts @@ -0,0 +1,220 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +describe('Multiline editing preserves text', () => { + for (const separator of [',', '\t']) { + for (const header of [true, false]) { + it(`keeps repeated Shift+Enter lines on blur (${JSON.stringify(separator)}, header ${header})`, async () => { + const host = await createEditorHarness({ separator, header }); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + for (const line of ['second', 'third', 'fourth']) { + view.key('Enter', { shiftKey: true }); + view.type(line); + } + cell.blur(); + const edit = view.messages.find(message => message.type === 'editCell'); + assert.strictEqual(edit.value, 'one\nsecond\nthird\nfourth'); + assert.strictEqual(cell.querySelector('[data-csv-newline-sentinel]'), null); + await host.receive(edit); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond\nthird\nfourth', 'two']]); + } finally { + view.close(); + } + }); + } + } + + it('inserts exactly one break in the middle and at the end after reopening an edit', async () => { + const host = await createEditorHarness({ rows: [['name'], ['first\nlast']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + view.caret(cell.firstChild, 5); + view.key('Enter', { shiftKey: true }); + cell.blur(); + view.start(cell); + view.key('Enter', { shiftKey: true }); + view.type('final'); + cell.blur(); + assert.strictEqual(view.messages.filter(message => message.type === 'editCell').at(-1)?.value, 'first\n\nlast\nfinal'); + } finally { + view.close(); + } + }); + + it('starts Shift+Enter from selection without a delayed caret moving after the placeholder', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.window.editingTestApi.setSingleSelection(cell); + view.key('Enter', { shiftKey: true }); + await new Promise(resolve => setTimeout(resolve, 20)); + const selection = view.window.getSelection(); + assert.strictEqual(selection.anchorNode, cell); + assert.strictEqual(cell.childNodes[selection.anchorOffset].nodeName, 'BR'); + view.type('second'); + cell.blur(); + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'one\nsecond'); + } finally { + view.close(); + } + }); + + it('commits the full multiline value before a save message', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('second'); + view.key('s', { ctrlKey: true }); + const operations = view.messages.filter(message => message.type === 'editCell' || message.type === 'save'); + assert.deepStrictEqual(operations.map(message => message.type), ['editCell', 'save']); + await Promise.all(operations.map(message => host.receive(message))); + assert.strictEqual(host.diskText, host.document.getText()); + assert.strictEqual(host.rows()[1][0], 'one\nsecond'); + } finally { + view.close(); + } + }); + + it('does not replace an active edit with an earlier update acknowledgment', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + view.type(' changed'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: { + type: 'updateCell', row: 1, col: 0, value: 'old', rendered: 'old' + } })); + cell.blur(); + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'one changed'); + } finally { + view.close(); + } + }); + + for (const leave of ['window blur', 'hidden document']) { + it(`commits an active multiline edit on ${leave}`, async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('second'); + if (leave === 'window blur') { + view.window.dispatchEvent(new view.window.Event('blur')); + } else { + Object.defineProperty(view.window.document, 'visibilityState', { value: 'hidden' }); + view.window.document.dispatchEvent(new view.window.Event('visibilitychange')); + } + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'one\nsecond'); + } finally { + view.close(); + } + }); + } + + it('restores the original multiline value when cancelling an edit', async () => { + const host = await createEditorHarness({ rows: [['name'], ['first\nlast']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.type(' changed'); + view.key('Escape'); + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'first\nlast'); + } finally { + view.close(); + } + }); +}); + +describe('Document operations use current unsaved contents', () => { + it('native Save waits for a pending blur edit', async () => { + const host = await createEditorHarness(); + host.deferEdits(); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + await new Promise(resolve => setImmediate(resolve)); + const save = host.document.save(); + host.releaseEdit(); + await Promise.all([edit, save]); + assert.ok(host.diskText.includes('second')); + }); + + it('waits for a pending edit before saving and applying the next edit', async () => { + const host = await createEditorHarness(); + host.deferEdits(); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + const save = host.receive({ type: 'save' }); + const nextEdit = host.receive({ type: 'editCell', row: 1, col: 1, value: 'next' }); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + assert.ok(!host.diskText.includes('second')); + host.releaseEdit(); + await edit; + await save; + assert.ok(host.diskText.includes('second')); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + host.releaseEdit(); + await nextEdit; + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond', 'next']]); + }); + + for (const separator of [',', '\t']) { + for (const header of [true, false]) { + it(`inserts rows after a pending multiline edit (${JSON.stringify(separator)}, header ${header})`, async () => { + const host = await createEditorHarness({ separator, header }); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + const insert = host.receive({ type: 'insertRow', index: 2 }); + const fill = host.receive({ type: 'editCell', row: 2, col: 0, value: 'new' }); + await Promise.all([edit, insert, fill]); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond', 'two'], ['new', '']]); + }); + } + } + + it('rebuilds a hidden remote webview from dirty contents and supplies current later chunks', async () => { + const rows = [['name', 'value'], ...Array.from({ length: 1100 }, (_, index) => [`row-${index}`, 'old'])]; + const host = await createEditorHarness({ rows, remote: true }); + const originalHtml = host.panel.webview.html; + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'unsaved-first' }); + await host.receive({ type: 'editCell', row: 1050, col: 1, value: 'unsaved-later' }); + assert.strictEqual(host.panel.webview.html, originalHtml); + await host.view(false, false); + await host.view(true, true); + assert.ok(host.panel.webview.html.includes('unsaved-first')); + assert.ok(!host.diskText.includes('unsaved')); + const current = createWebviewDom(host.panel.webview.html); + try { + const metadata = JSON.parse(current.window.document.getElementById('__csvFilters').textContent); + await host.receive({ type: 'requestChunk', start: 1001, requestId: 1, filterEpoch: 0, session: metadata.session }); + assert.ok(host.messages.find(message => message.type === 'chunkData').html.includes('unsaved-later')); + } finally { current.close(); } + }); + + it('keeps one virtual row and ignores an empty edit on it after multiline editing', async () => { + const host = await createEditorHarness(); + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + await host.receive({ type: 'editCell', row: 2, col: 0, value: '' }); + await host.view(false, false); + await host.view(true, true); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond', 'two']]); + const view = createWebviewDom(host.panel.webview.html); + try { + const dataRows = view.window.document.querySelectorAll('tbody tr'); + assert.strictEqual(dataRows.length, 2); + assert.strictEqual(view.cell(2, 0).textContent, ''); + assert.strictEqual(view.cell(3, 0), null); + } finally { + view.close(); + } + }); +}); diff --git a/src/test/editor-associations.test.ts b/src/test/editor-associations.test.ts new file mode 100644 index 0000000..3541a53 --- /dev/null +++ b/src/test/editor-associations.test.ts @@ -0,0 +1,447 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { CsvEditorAssociationRouting, csvEditorSelectors, exactFileEditorPattern } from '../editor-associations'; + +type MapValue = Record | undefined; +const clone = (value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)); +function fixture(options: { global?: MapValue; workspace?: MapValue; globalEnabled?: boolean; workspaceEnabled?: boolean; hasWorkspace?: boolean; defaults?: MapValue } = {}) { + const data = { + global: clone(options.global), workspace: clone(options.workspace), + globalEnabled: options.globalEnabled, workspaceEnabled: options.workspaceEnabled, + storeAfterWait: false, fail: false, ignoreWrite: false, policy: undefined as MapValue, beforeStorage: undefined as (() => Promise) | undefined, beforeWrite: undefined as (() => Promise) | undefined, + writes: [] as Array<{ target: number; value: MapValue }>, storageWrites: 0 + }; + const globalValues = new Map(), workspaceValues = new Map(); + const store = (values: Map) => ({ + get: (key: string) => clone(values.get(key)), + keys: () => [...values.keys()].filter(key => values.get(key) !== undefined), + update: async (key: string, value: unknown) => { data.storageWrites++; if (!data.storeAfterWait) { values.set(key, clone(value)); } await data.beforeStorage?.(); if (data.storeAfterWait) { values.set(key, clone(value)); } } + }); + const context = { globalState: store(globalValues), workspaceState: store(workspaceValues) } as any; + const api = { + ConfigurationTarget: { Global: 1, Workspace: 2 }, + workspace: { + workspaceFolders: options.hasWorkspace === false ? undefined : [{}], + workspaceFile: undefined, + getConfiguration: (section: string) => section === 'csv' ? { + get: () => data.workspaceEnabled ?? data.globalEnabled ?? true, + inspect: () => ({ defaultValue: true, globalValue: data.globalEnabled, workspaceValue: data.workspaceEnabled }) + } : { + inspect: () => ({ defaultValue: clone(options.defaults), globalValue: clone(data.global), workspaceValue: clone(data.workspace) }), + get: () => clone(data.policy ?? { ...(options.defaults || {}), ...(data.global || {}), ...(data.workspace || {}) }), + update: async (_key: string, value: MapValue, target: number) => { + await data.beforeWrite?.(); + if (data.fail) { throw new Error('settings are read-only'); } + data.writes.push({ target, value: clone(value) }); + if (!data.ignoreWrite) { if (target === 1) { data.global = clone(value); } else { data.workspace = clone(value); } } + } + } + } + } as any; + return { data, context, api, globalValues, workspaceValues, manager: () => new CsvEditorAssociationRouting(context, api) }; +} +const native = () => Object.fromEntries(csvEditorSelectors.map(pattern => [pattern, 'default'])); + +describe('Reversible disabled CSV editor associations', () => { + it('routes all four selectors and more-specific CSV choices natively while preserving other editors', async () => { + const original = { '**/special.csv': 'csv.editor', '*.tsv': 'another.editor', '*.tab': 'default', '*.md': 'markdown.preview' }; + const h = fixture({ global: original, globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { ...native(), ...original, '**/special.csv': 'default', '*.csv': 'default', '*.psv': 'default' }); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, original); + }); + + for (const original of [undefined, {}]) { + it(`restores an originally ${original ? 'empty' : 'absent'} settings object exactly`, async () => { + const h = fixture({ global: original, globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, native()); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, original); + }); + } + + it('limits workspace Off to workspace settings, including inherited specific CSV choices', async () => { + const global = { '**/data.csv': 'csv.editor', '*.tsv': 'external.tsv' }; + const workspace = { '*.psv': 'external.psv' }; + const h = fixture({ global, workspace, workspaceEnabled: false }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, global); + assert.deepStrictEqual(h.data.workspace, { ...workspace, '*.csv': 'default', '*.tab': 'default', '**/data.csv': 'default' }); + assert.ok(h.data.writes.every(write => write.target === 2)); + h.data.workspaceEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.workspace, workspace); + }); + + it('uses workspace On counterbindings only for this extension’s inherited global shadows', async () => { + const h = fixture({ globalEnabled: false, workspaceEnabled: true, global: { '**/data.csv': 'csv.editor', '*.tsv': 'external.tsv' }, workspace: { '*.psv': 'external.psv' } }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'default', '*.tab': 'default', '*.psv': 'default', '**/data.csv': 'default', '*.tsv': 'external.tsv' }); + assert.deepStrictEqual(h.data.workspace, { '*.psv': 'external.psv', '*.csv': 'csv.editor', '*.tab': 'csv.editor', '**/data.csv': 'csv.editor' }); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { '**/data.csv': 'csv.editor', '*.tsv': 'external.tsv' }); + assert.deepStrictEqual(h.data.workspace, { '*.psv': 'external.psv' }); + }); + + it('does not add redundant workspace overrides when native global routing is inherited', async () => { + const h = fixture({ globalEnabled: false }); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, native()); + assert.strictEqual(h.data.workspace, undefined); + }); + + it('restores preferences after restart and preserves unrelated additions made while disabled', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false, global: { '*.csv': 'csv.editor' } }); + await h.manager().synchronize(); + h.data.global = { ...h.data.global, '*.json': 'external.json' }; + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'csv.editor', '*.json': 'external.json' }); + }); + + it('does not overwrite an intervening choice of another editor or native editor on re-enable', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + h.data.global = { ...h.data.global, '*.csv': 'external.csv' }; + await routing.synchronize(); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'external.csv' }); + }); + + it('preserves an intervening deletion or explicit CSV choice and reports the routing conflict', async () => { + for (const replacement of [undefined, 'csv.editor']) { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + if (replacement === undefined) { delete h.data.global!['*.csv']; } + else { h.data.global!['*.csv'] = replacement; } + const current = clone(h.data.global); + await assert.rejects(routing.synchronize(), /preserved your changed editor associations/); + assert.deepStrictEqual(h.data.global, current); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, replacement === undefined ? {} : { '*.csv': replacement }); + } + }); + + it('fails clearly on a denied write and can retry without a poisoned ownership record', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + h.data.fail = true; + await assert.rejects(routing.synchronize(), /could not update global editor routing: settings are read-only/); + assert.strictEqual(h.data.global, undefined); + h.data.fail = false; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, native()); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.strictEqual(h.data.global, undefined); + }); + + it('stores recovery metadata before settings change and avoids repeated writes when synchronized', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.beforeWrite = async () => { + assert.ok([...h.globalValues.values()].some((value: any) => Object.keys(value?.state?.entries || {}).length === 4)); + }; + const routing = h.manager(); + await routing.synchronize(); + const writes = h.data.writes.length; + await routing.synchronize(); + assert.strictEqual(h.data.writes.length, writes); + }); + it('preserves blanket and more-specific external choices while converting specific CSV overrides', async () => { + for (const blanket of ['*', '**/*', '*.*']) { + const original = { [blanket]: 'external.editor', '**/special.csv': 'csv.editor', '/path/*.csv': 'external.path' }; + const h = fixture({ global: original, globalEnabled: false, hasWorkspace: false }); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { ...original, '**/special.csv': 'default' }); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, original); + } + const h = fixture({ global: { 'a*': 'external.editor' }, globalEnabled: false, hasWorkspace: false }); + await assert.rejects(h.manager().synchronize(), /preserved overlapping editor associations/); + assert.deepStrictEqual(h.data.global, { 'a*': 'external.editor' }); + }); + + it('handles inherited blanket choices and more-specific CSV associations without global writes', async () => { + const global = { '**/*': 'external.editor', '**/special.csv': 'csv.editor' }; + const h = fixture({ global, workspaceEnabled: false }); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, global); + assert.deepStrictEqual(h.data.workspace, { '**/special.csv': 'default' }); + }); + + it('shadows contributed default CSV associations and detects policy-valued routing', async () => { + const h = fixture({ defaults: { '**/special.csv': 'csv.editor', '*.tsv': 'external.editor' }, globalEnabled: false, hasWorkspace: false }); + await h.manager().synchronize(); + assert.strictEqual(h.data.global!['**/special.csv'], 'default'); + assert.strictEqual(h.data.global!['*.tsv'], undefined); + h.data.policy = { '*.csv': 'csv.editor' }; + await assert.rejects(h.manager().synchronize(), /policy or another settings scope/); + }); + + it('detects an ignored settings write and can recover without ownership debris', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.ignoreWrite = true; + await assert.rejects(h.manager().synchronize(), /did not apply the routing preferences/); + assert.strictEqual(h.data.global, undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + h.data.ignoreWrite = false; + await h.manager().synchronize(); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.strictEqual(h.data.global, undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined), 'final state removes provisional ownership'); + }); + + it('does not write settings after disposal during recovery metadata persistence', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + h.data.beforeStorage = async () => { h.data.beforeStorage = undefined; routing.dispose(); }; + await routing.synchronize(); + assert.strictEqual(h.data.writes.length, 0); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + it('finishes recovery ownership if disposed during an in-flight settings write', async () => { + const h = fixture({ globalEnabled: false, workspaceEnabled: true }); + const routing = h.manager(); + h.data.beforeWrite = async () => { routing.dispose(); }; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, native()); + assert.strictEqual(h.data.workspace, undefined); + h.data.beforeWrite = undefined; + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.strictEqual(h.data.global, undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + it('does not overwrite a user map changed while recovery data was being persisted', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.beforeStorage = async () => { h.data.beforeStorage = undefined; h.data.global = { '*.csv': 'external.editor' }; }; + await assert.rejects(h.manager().synchronize(), /changed while CSV was updating/); + assert.deepStrictEqual(h.data.global, { '*.csv': 'external.editor' }); + }); + + it('keeps recovery when another window has already installed the same temporary routing', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.storeAfterWait = true; + let releaseFirst!: () => void, releaseSecond!: () => void; + let count = 0; + h.data.beforeStorage = async () => { + const operation = ++count; + if (operation === 1) { await new Promise(resolve => { releaseFirst = resolve; }); } + if (operation === 2) { await new Promise(resolve => { releaseSecond = resolve; }); } + }; + const first = h.manager().synchronize(); + while (!releaseFirst) { await new Promise(resolve => setImmediate(resolve)); } + const second = h.manager().synchronize(); + while (!releaseSecond) { await new Promise(resolve => setImmediate(resolve)); } + releaseFirst(); await first; + assert.deepStrictEqual(h.data.global, native()); + releaseSecond(); await second; + assert.deepStrictEqual(h.data.global, native()); + assert.strictEqual(h.data.writes.length, 1); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.strictEqual(h.data.global, undefined, 'last window retains the original absence for restoration'); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + for (const original of [undefined, 'csv.editor']) { + it(`does not let an older coalesced plan replace a newer user's ${original ? 'explicit CSV choice' : 'deletion'}`, async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false, global: original ? undefined : { '*.csv': 'csv.editor' } }); + h.data.storeAfterWait = true; + let release!: () => void; + let held = false; + h.data.beforeStorage = async () => { + if (!held) { held = true; await new Promise(resolve => { release = resolve; }); } + }; + const pending = h.manager().synchronize(); + while (!release) { await new Promise(resolve => setImmediate(resolve)); } + h.data.global = original ? { '*.csv': original } : undefined; + await h.manager().synchronize(); + release(); await pending; + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, original ? { '*.csv': original } : undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + } + + it('keeps a newer window’s extra original binding when a delayed older plan conflicts', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.storeAfterWait = true; + let release!: () => void; + let held = false; + h.data.beforeStorage = async () => { + if (!held) { held = true; await new Promise(resolve => { release = resolve; }); } + }; + const pending = h.manager().synchronize(); + while (!release) { await new Promise(resolve => setImmediate(resolve)); } + h.data.global = { '**/special.csv': 'csv.editor' }; + await h.manager().synchronize(); + release(); await assert.rejects(pending, /changed while CSV was updating/); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { '**/special.csv': 'csv.editor' }); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + it('durably relinquishes an observed user edit even when no settings write is necessary', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + await h.manager().synchronize(); + h.data.global!['*.csv'] = 'external.editor'; + const writeCount = h.data.writes.length; + await h.manager().synchronize(); + assert.strictEqual(h.data.writes.length, writeCount, 'external choice needs no preference mutation'); + h.data.global!['*.csv'] = 'default'; + await h.manager().synchronize(); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'default' }, 'later user native choice remains on restart/re-enable'); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + +}); + + +const file = (path: string, fields: Partial> = {}) => ({ scheme: 'file', authority: '', query: '', fragment: '', path, ...fields } as any); +describe('Continuing per-file Text view routing', () => { + it('uses literal scheme:path patterns and rejects URI identities the workbench cannot isolate', () => { + assert.strictEqual(exactFileEditorPattern(file('/one [x]*?{y}/check.csv')), 'file:/one [[]x[]][*][?][{]y[}]/check.csv'); + for (const fields of [{ scheme: 'vscode-remote', authority: 'wsl+Ubuntu' }, { authority: 'server' }, { query: 'revision=1' }, { fragment: 'line50' }]) { + assert.strictEqual(exactFileEditorPattern(file('/check.csv', fields)), undefined); + } + }); + + it('keeps a workspace Text choice across synchronization, restart and Off→On', async () => { + const h = fixture(), uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const text = await h.manager().prepareEditorSwitch(uri, 'default'); await text.commit(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }); assert.strictEqual(h.data.global, undefined); + await h.manager().synchronize(); + h.data.globalEnabled = false; await h.manager().synchronize(); + h.data.globalEnabled = true; await h.manager().synchronize(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }); assert.strictEqual(h.data.global, undefined); + const table = await h.manager().prepareEditorSwitch(uri, 'csv.editor'); + await table.rollback(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }, 'cancelled Table does not change Text routing'); + const successfulTable = await h.manager().prepareEditorSwitch(uri, 'csv.editor'); await successfulTable.commit(); + assert.strictEqual(h.data.workspace, undefined); assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + + it('releases only one file while another Text choice remains, without resurrecting older journals', async () => { + const h = fixture(), a = file('/one/check.csv'), b = file('/two/check.csv'); + await (await h.manager().prepareEditorSwitch(a, 'default')).commit(); + await (await h.manager().prepareEditorSwitch(b, 'default')).commit(); + await (await h.manager().prepareEditorSwitch(b, 'csv.editor')).commit(); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.workspace, { [exactFileEditorPattern(a)!]: 'default' }); + await (await h.manager().prepareEditorSwitch(a, 'csv.editor')).commit(); + assert.strictEqual(h.data.workspace, undefined); + }); + + for (const original of [undefined, {}]) { + it(`rolls a cancelled Text switch back to an originally ${original ? 'empty' : 'absent'} preference object`, async () => { + const h = fixture({ workspace: original }), uri = file('/one/check.tsv'); + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + await transaction.rollback(); + assert.deepStrictEqual(h.data.workspace, original); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + } + + it('preserves an unrelated concurrent preference and a user edit to the owned key on rollback/Table return', async () => { + const h = fixture(), uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + h.data.workspace!['*.md'] = 'markdown.preview'; + h.data.workspace![pattern] = 'external.editor'; + await transaction.rollback(); + await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); + assert.deepStrictEqual(h.data.workspace, { '*.md': 'markdown.preview', [pattern]: 'external.editor' }); + }); + + it('restores an existing exact CSV choice and preserves a preexisting native or external choice', async () => { + const uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + for (const prior of ['csv.editor', 'default', 'external.editor']) { + const h = fixture({ workspace: { [pattern]: prior } }); + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + assert.strictEqual(transaction.supported, prior !== 'external.editor'); + await transaction.commit(); + await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: prior }); + } + }); + + it('internally rolls back a provisional Text write when enablement changes during preparation', async () => { + const h = fixture(), uri = file('/one/check.csv'); + h.data.beforeWrite = async () => { h.data.beforeWrite = undefined; h.data.globalEnabled = false; }; + await assert.rejects(h.manager().prepareEditorSwitch(uri, 'default'), /routing changed while switching/); + assert.strictEqual(h.data.workspace, undefined); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + + it('internally rolls back a provisional Text write if disposed while it is applying', async () => { + const h = fixture(), uri = file('/one/check.csv'), manager = h.manager(); + h.data.beforeWrite = async () => { h.data.beforeWrite = undefined; manager.dispose(); }; + await assert.rejects(manager.prepareEditorSwitch(uri, 'default'), /routing changed while switching/); + assert.strictEqual(h.data.workspace, undefined); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + + it('does not leave ownership after denied or ignored provisional writes', async () => { + for (const failure of ['fail', 'ignoreWrite'] as const) { + const h = fixture(); h.data[failure] = true; + await assert.rejects(h.manager().prepareEditorSwitch(file('/one/check.csv'), 'default')); + assert.strictEqual(h.data.workspace, undefined); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + } + }); + + it('leaves unsupported remote view switching available without changing any associations', async () => { + const h = fixture(); + const transaction = await h.manager().prepareEditorSwitch(file('/one/check.csv', { scheme: 'vscode-remote', authority: 'wsl+Ubuntu' }), 'default'); + assert.strictEqual(transaction.supported, false); assert.match(transaction.limitation!, /Turn CSV off/); + await transaction.commit(); await transaction.rollback(); + assert.strictEqual(h.data.writes.length, 0); + }); + it('does not shadow an inherited exact choice of another editor', async () => { + const uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const h = fixture({ global: { [pattern]: 'external.editor' } }); + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + assert.strictEqual(transaction.supported, false); + assert.strictEqual(h.data.workspace, undefined); + assert.deepStrictEqual(h.data.global, { [pattern]: 'external.editor' }); + }); + + for (const observedBy of ['table', 'rollback']) { + for (const userValue of ['external.editor', undefined]) { + it(`durably relinquishes a user ${userValue ? 'editor change' : 'deletion'} observed by ${observedBy} before the user chooses native again`, async () => { + const h = fixture(), uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const text = await h.manager().prepareEditorSwitch(uri, 'default'); await text.commit(); + if (userValue === undefined) { delete h.data.workspace![pattern]; } + else { h.data.workspace![pattern] = userValue; } + if (observedBy === 'table') { await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); } + else { await text.rollback(); } + h.data.workspace![pattern] = 'default'; + await h.manager().synchronize(); + await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }); + }); + } + } + +}); diff --git a/src/test/editor-lifecycle.test.ts b/src/test/editor-lifecycle.test.ts new file mode 100644 index 0000000..2d81a67 --- /dev/null +++ b/src/test/editor-lifecycle.test.ts @@ -0,0 +1,490 @@ +import assert from 'assert'; +import Module from 'module'; +import { beforeEach, describe, it } from 'node:test'; + +class Uri { + constructor(public scheme: string, public authority: string, public path: string) {} + get fsPath() { return this.path; } + toString() { return `${this.scheme}://${this.authority}${this.path}`; } + with(changes: { path: string }) { + return new Uri(this.scheme, this.authority, changes.path); + } +} + +const state = { + enabled: true, + activeGroup: undefined as any, + groups: [] as any[], + focusListeners: new Set<(...args: any[]) => void>(), + shown: [] as any[], + visibleTextEditors: [] as any[], + commands: [] as any[][], + stat: async (): Promise<{ size: number }> => ({ size: 12 }), + executeCommand: async (..._args: any[]): Promise => {}, + documentListeners: new Set<(...args: any[]) => void>() +}; +const vscode = { + Uri, + ColorThemeKind: { Dark: 1 }, + window: { + activeColorTheme: { kind: 1 }, + tabGroups: { get all() { return state.groups; }, get activeTabGroup() { return state.activeGroup; } }, + onDidChangeActiveTextEditor: (callback: (...args: any[]) => void) => { + state.focusListeners.add(callback); + return { dispose: () => state.focusListeners.delete(callback) }; + }, + showTextDocument: async (document: any, options: any) => { + state.shown.push({ document, options }); + const editor = { document, viewColumn: options.viewColumn }; + state.visibleTextEditors = [editor]; + state.activeGroup = { viewColumn: options.viewColumn, activeTab: { input: { uri: document.uri } } }; + state.focusListeners.forEach(callback => callback(editor)); + return editor; + }, + get visibleTextEditors() { return state.visibleTextEditors; } + }, + workspace: { + getConfiguration: () => ({ + get: (key: string, fallback: any) => key === 'enabled' ? state.enabled : fallback + }), + fs: { stat: () => state.stat() }, + onWillSaveTextDocument: () => ({ dispose() {} }), + onDidChangeTextDocument: (callback: (...args: any[]) => void) => { + state.documentListeners.add(callback); + return { dispose: () => state.documentListeners.delete(callback) }; + } + }, + commands: { + executeCommand: async (...args: any[]) => { + state.commands.push(args); + await state.executeCommand(...args); + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return vscode; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +const context = { + extensionUri: new Uri('vscode-remote', 'wsl+Ubuntu', '/home/user/.vscode-server/extensions/csv'), + // A path alone cannot carry the remote authority of extensionUri. + extensionPath: '/home/user/.vscode-server/extensions/csv', + workspaceState: { get: (_key: string, fallback: any) => fallback } +}; + +function makePanel(viewColumn = 1) { + const disposeListeners: Array<() => void> = []; + const viewStateListeners: Array<(event: any) => void> = []; + const messages: any[] = []; + const resources: Uri[] = []; + let html = ''; + let disposed = false; + let receiveMessage: (message: any) => Promise = async () => {}; + const panel = { + active: true, + visible: true, + viewColumn, + title: 'test.csv', + get disposed() { return disposed; }, + webview: { + options: {} as { localResourceRoots?: Uri[] }, + cspSource: 'vscode-webview:', + get html() { return html; }, + set html(value: string) { + assert.strictEqual(disposed, false, 'must not render into a disposed webview'); + html = value; + }, + asWebviewUri: (uri: Uri) => { resources.push(uri); return uri; }, + postMessage: (message: any) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: (listener: typeof receiveMessage) => { receiveMessage = listener; return { dispose() {} }; } + }, + onDidDispose: (callback: () => void) => { + disposeListeners.push(callback); + return { dispose() {} }; + }, + onDidChangeViewState: (callback: (event: any) => void) => { + viewStateListeners.push(callback); + return { dispose() {} }; + }, + dispose: () => { + if (!disposed) { + disposed = true; + disposeListeners.forEach(callback => callback()); + } + }, + receive: (message: any) => receiveMessage(message), + setActive: (active: boolean) => { + panel.active = active; + viewStateListeners.forEach(callback => callback({ webviewPanel: panel })); + } + }; + return { panel, messages, resources }; +} + +function makeDocument(uri: Uri, text = 'name,value\nfirst,1') { + return { uri, version: 1, getText: () => text }; +} + +async function resolve(document: ReturnType, panel: ReturnType['panel'], token = { isCancellationRequested: false }) { + const provider = new CsvEditorProvider(context as any); + await provider.resolveCustomTextEditor(document as any, panel as any, token as any); +} + +beforeEach(() => { + state.enabled = true; + state.activeGroup = undefined; + state.groups = []; + state.focusListeners.clear(); + state.shown = []; + state.visibleTextEditors = []; + state.commands = []; + state.stat = async () => ({ size: 12 }); + state.executeCommand = async () => {}; + state.documentListeners.clear(); + CsvEditorProvider.editors = []; + CsvEditorProvider.currentActive = undefined; +}); + +describe('CSV editor lifecycle and native text handoff', () => { + for (const [extension, text, separatorCode] of [ + ['csv', 'name,value\nfirst,1', 44], + ['tsv', 'name\tvalue\nfirst\t1', 9] + ] as const) { + for (const scheme of ['file', 'vscode-remote']) { + it(`preserves the ${scheme} ${extension.toUpperCase()} controller and resources across tab changes`, async () => { + const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/workspace/data.${extension}`); + const document = makeDocument(uri, text); + const { panel, messages, resources } = makePanel(); + await resolve(document, panel); + assert.ok(panel.webview.html.includes(`data-sepcode="${separatorCode}"`)); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.strictEqual(state.documentListeners.size, 1); + const controller = CsvEditorProvider.getActiveProvider(); + assert.ok(controller); + + panel.setActive(false); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + panel.setActive(true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), controller); + assert.deepStrictEqual(messages[messages.length - 1], { type: 'focus' }); + assert.strictEqual(state.commands.length, 0, 'tab changes must not reopen or replace editors'); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.strictEqual(resources[0].toString(), `${context.extensionUri}/media/main.js`); + assert.deepStrictEqual(resources.map(resource => resource.path.split('/').pop()).sort(), ['column-filters.js', 'frozen-panes.js', 'high-contrast.css', 'main.js']); + assert.ok(resources.every(resource => resource.scheme === context.extensionUri.scheme && resource.authority === context.extensionUri.authority)); + assert.strictEqual(panel.webview.options.localResourceRoots?.[0].toString(), `${context.extensionUri}/media`); + panel.dispose(); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + assert.strictEqual(state.documentListeners.size, 0); + }); + } + } + + it('does not register or render a preview closed during a slow remote file check', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const pending = resolve(makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')), panel); + panel.dispose(); + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(state.documentListeners.size, 0); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + + state.stat = async () => ({ size: 12 }); + const replacement = makePanel(); + await resolve(makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')), replacement.panel); + assert.ok(replacement.panel.webview.html.includes('')); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.ok(CsvEditorProvider.getActiveProvider()); + replacement.panel.dispose(); + }); + + it('honors cancellation during asynchronous initialization', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const token = { isCancellationRequested: false }; + const pending = resolve(makeDocument(new Uri('file', '', '/data.csv')), panel, token); + token.isCancellationRequested = true; + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(state.documentListeners.size, 0); + }); + + for (const extension of ['csv', 'tsv']) { + it(`keeps an already visible ${extension.toUpperCase()} search destination when disabled`, async () => { + state.enabled = false; + const document = makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', `/data.${extension}`)); + const selection = { start: { line: 87, character: 4 }, end: { line: 87, character: 10 } }; + const editor = { document, viewColumn: 2, selection }; + state.visibleTextEditors.push(editor); + const { panel } = makePanel(2); + await resolve(document, panel); + assert.strictEqual(state.commands.length, 0, 'must not reopen a text editor already at the requested result'); + assert.strictEqual(editor.selection, selection); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); + } + + it('reopens the correct group once when disabling with no visible text editor', async () => { + const document = makeDocument(new Uri('file', '', '/data.csv')); + const { panel, messages } = makePanel(2); + await resolve(document, panel); + const controller = CsvEditorProvider.getActiveProvider()!; + panel.setActive(false); + let finishOpen!: () => void; + state.executeCommand = () => new Promise(resolveOpen => { finishOpen = resolveOpen; }); + state.enabled = false; + controller.refresh(); + controller.refresh(); + assert.strictEqual(state.commands.length, 0, 'disable waits for the current webview draft'); + const request = messages.filter(message => message.type === 'prepareViewRefresh').slice(-1)[0]; + const metadata = JSON.parse(/ & "Name"'; + const host = await createEditorHarness({ rows: [[label], ['one']], filters: [{ column: 0, operator: 'regex', value: '[' }] }); + const initial = metadata(host); + assert.strictEqual(initial.columns[0].label, label); + assert.strictEqual(initial.filters[0].value, '['); + const dom = new JSDOM(host.panel.webview.html); + try { + assert.strictEqual(dom.window.document.querySelector('img'), null); + assert.strictEqual(dom.window.document.querySelectorAll('#__csvFilters').length, 1); + assert.deepStrictEqual(rowIds(host.panel.webview.html), [2]); + } finally { dom.window.close(); } + }); + + it('coalesces queued revisions after preceding document edits and keeps the newest source text unsaved', async () => { + const host = await createEditorHarness({ rows: [['Amount', 'Note'], ['2', 'one'], ['10', 'two']] }); + host.deferEdits(); + const pendingEdit = host.receive({ type: 'editCell', row: 1, col: 1, value: 'unposted\nmultiline' }); + await new Promise(resolve => setImmediate(resolve)); + const session = metadata(host).session; + const old = host.receive({ type: 'updateColumnFilters', revision: 1, session, filters: [{ column: 0, operator: 'greaterThan', value: '0' }] }); + const newest = host.receive({ type: 'updateColumnFilters', revision: 2, session, filters: [{ column: 0, operator: 'greaterThan', value: '5' }] }); + host.releaseEdit(); + await Promise.all([pendingEdit, old, newest]); + const replies = host.messages.filter(message => message.type === 'columnFiltersResult'); + assert.deepStrictEqual(replies.map(message => message.revision), [2]); + assert.deepStrictEqual(rowIds(replies[0].tableHtml), [2, 3]); + assert.strictEqual(host.rows()[1][1], 'unposted\nmultiline'); + assert.ok(!host.diskText.includes('multiline')); + }); + + it('rolls back a superseded preference write before validating the newest snapshot', async () => { + const host = await createEditorHarness({ rows: [['Amount'], ['2'], ['10']] }); + const state = host.context.workspaceState; + const originalUpdate = state.update.bind(state); + let release = () => {}; + let held = false; + state.update = async (key, value) => { + if (key === CsvEditorProvider.columnFiltersKey && !held) { + held = true; + await new Promise(resolve => { release = resolve; }); + } + await originalUpdate(key, value); + }; + const session = metadata(host).session; + const old = host.receive({ type: 'updateColumnFilters', revision: 1, session, filters: [{ column: 0, operator: 'greaterThan', value: '3' }] }); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(held); + const newest = host.receive({ type: 'updateColumnFilters', revision: 2, session, filters: [{ column: 0, operator: 'regex', value: '[' }] }); + release(); + await Promise.all([old, newest]); + assert.deepStrictEqual(saved(host), []); + const replies = host.messages.filter(message => message.type === 'columnFiltersResult'); + assert.deepStrictEqual(replies.map(message => message.revision), [2]); + assert.ok(replies[0].errors); + assert.strictEqual(replies[0].filterEpoch, 0); + assert.deepStrictEqual(rowIds(replies[0].tableHtml), [1, 2, 3]); + }); + + it('does not relabel an old-session asynchronous storage error as a new-view response', async () => { + const host = await createEditorHarness({ rows: [['Amount'], ['2'], ['10']] }); + const state = host.context.workspaceState; + const originalUpdate = state.update.bind(state); + let reject = () => {}; + state.update = async () => new Promise((_resolve, fail) => { reject = () => fail(new Error('test storage rejection')); }); + const oldSession = metadata(host).session; + const pending = host.receive({ type: 'updateColumnFilters', revision: 1, session: oldSession, filters: [{ column: 0, operator: 'greaterThan', value: '3' }] }); + await new Promise(resolve => setImmediate(resolve)); + CsvEditorProvider.getActiveProvider()!.refresh(); + await new Promise(resolve => setTimeout(resolve, 20)); + const fresh = metadata(host); + assert.notStrictEqual(fresh.session, oldSession); + reject(); + await pending; + assert.ok(!host.messages.some(message => message.type === 'columnFiltersResult')); + state.update = originalUpdate; + const accepted = await update(host, 1, [], fresh.session); + assert.strictEqual(accepted.session, fresh.session); + assert.strictEqual(accepted.filterEpoch, 1); + }); + + it('rejects redraw after false or thrown WorkspaceEdit and accepts a successful retry of that cell', async () => { + for (const failure of ['false', 'throw']) { + const host = await createEditorHarness({ rows: [['Amount', 'Note'], ['2', 'one'], ['10', 'two']] }); + const apply = host.applyEdit; + host.applyEdit = async () => { if (failure === 'throw') { throw new Error('test edit rejection'); } return false; }; + await host.receive({ type: 'editCell', row: 1, col: 1, value: 'unapplied draft' }); + const blocked = await update(host, 1, [{ column: 0, operator: 'greaterThan', value: '0' }]); + assert.ok(blocked.error.includes('failed cell edit')); + assert.strictEqual(blocked.filterEpoch, 0); + assert.deepStrictEqual(saved(host), []); + host.applyEdit = apply; + await host.receive({ type: 'editCell', row: 1, col: 1, value: 'retry draft' }); + const view = createWebviewDom(host.panel.webview.html); + try { await flushViewCommits(host, view); } + finally { view.close(); } + const result = await update(host, 2, [{ column: 0, operator: 'greaterThan', value: '0' }]); + assert.strictEqual(result.error, undefined); + assert.strictEqual(result.filterEpoch, 2); + assert.strictEqual(host.rows()[1][1], 'retry draft'); + } + }); + + it('scopes chunks and revisions to the accepted epoch and current webview session', async () => { + const host = await createEditorHarness({ rows: [['Name'], ...Array.from({ length: 1001 }, (_, index) => [`row${index}`])], remote: true }); + const initial = metadata(host), html = host.panel.webview.html; + const accepted = await update(host, 1, [{ column: 0, operator: 'notEmpty', value: '' }]); + const cursor = host.messages.length; + for (const request of [{ filterEpoch: 0, session: initial.session }, { filterEpoch: 1, session: 'old-view' }, { session: initial.session }]) { + await host.receive({ type: 'requestChunk', start: 1000, requestId: 'old', ...request }); + } + assert.strictEqual(host.messages.length, cursor, 'stale/malformed chunk requests cannot append an obsolete result'); + await host.receive({ type: 'requestChunk', start: 1000, requestId: 'current', filterEpoch: 1, session: initial.session }); + const chunk = host.messages.at(-1)!; + assert.strictEqual(chunk.filterEpoch, accepted.filterEpoch); + assert.strictEqual(chunk.session, initial.session); + assert.ok(chunk.html.includes('data-row="1001"')); + await host.view(false, false); await host.view(true, true); + const next = metadata(host); + assert.notStrictEqual(next.session, initial.session); + assert.strictEqual(next.revision, 0); + const afterRefresh = host.messages.length; + await update(host, 100, [], initial.session); + assert.strictEqual(host.messages.length, afterRefresh); + const reset = await update(host, 1, [], next.session); + assert.strictEqual(reset.filterEpoch, 1); + assert.strictEqual(reset.session, next.session); + assert.notStrictEqual(host.panel.webview.html, html, 'only the unrelated full refresh replaces HTML'); + }); +}); + + +describe('Volatile inline filter draft handoff', () => { + function draftFor(host: Host, rows = [{ column: 0, operator: 'contains', value: 'volatile draft' }]): any { + const initial = metadata(host); + return { + session: initial.session, + filterColumnsRevision: initial.filterColumnsRevision, + columns: initial.columns.map((column: any) => ({ column: column.column, label: column.label })), + rows + }; + } + async function acknowledge(host: Host, filterDraft: unknown, session = metadata(host).session) { + await host.receive({ type: 'viewRefreshReady', session, filterDraft }); + return metadata(host); + } + + for (const separator of [',', '\t']) { + it(`carries blank/invalid rows and caret once without storing drafts or changing source (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ rows: [['Name'], ['one'], ['two']], separator, filters: [{ column: 0, operator: 'contains', value: 'one' }] }); + const previous = JSON.stringify(host.context.workspaceState.values); + const source = host.document.getText(); + const draft = draftFor(host, [{ column: 0, operator: 'regex', value: 'volatile-[' }, { column: 0, operator: 'contains', value: '' }]); + draft.focus = { row: 0, field: 'value', selectionStart: 1, selectionEnd: 4, selectionDirection: 'backward' }; + const next = await acknowledge(host, draft); + assert.deepStrictEqual(next.filterDraft, draft); + assert.notStrictEqual(next.session, draft.session); + assert.strictEqual(next.filterColumnsRevision, draft.filterColumnsRevision); + assert.strictEqual(JSON.stringify(host.context.workspaceState.values), previous); + assert.strictEqual(host.document.getText(), source); + assert.strictEqual(host.diskText, source); + assert.strictEqual(host.document.version, 1); + assert.strictEqual((await acknowledge(host, undefined)).filterDraft, undefined, 'a later refresh does not replay the consumed draft'); + }); + } + + it('renews a queued ACK for current-session typing and ignores stale-session activity', async () => { + for (const stale of [false, true]) { + const host = await createEditorHarness({ rows: [['Name'], ['one']], filters: [{ column: 0, operator: 'contains', value: 'one' }] }); + const controller = CsvEditorProvider.getActiveProvider() as any; + let release = () => {}; + const gate = new Promise(resolve => { release = resolve; }); + const blocked = controller.enqueueOperation(() => gate) as Promise; + try { + await new Promise(resolve => setImmediate(resolve)); + const original = host.panel.webview.html; + const draft = draftFor(host, [{ column: 0, operator: 'contains', value: 'one' }]); + controller.refreshViewSafely(); + const request = host.messages.filter(message => message.type === 'prepareViewRefresh').at(-1)!; + const cursor = host.messages.length; + const acknowledging = host.receive({ type: 'viewRefreshReady', session: draft.session, requestId: request.requestId, filterDraft: draft }); + await host.receive({ type: 'filterDraftChanged', session: stale ? 'previous-view' : draft.session }); + assert.strictEqual(host.panel.webview.html, original); + release(); + await Promise.all([blocked, acknowledging]); + const renewed = host.messages.slice(cursor).find(message => message.type === 'prepareViewRefresh'); + if (stale) { + assert.strictEqual(renewed, undefined, 'old-view activity cannot renew a current ACK'); + assert.strictEqual(metadata(host).filterDraft.rows[0].value, 'one'); + } else { + assert.ok(renewed, 'typing received while an ACK waits must request a newer snapshot'); + assert.strictEqual(host.panel.webview.html, original, 'the stale snapshot cannot replace the live view'); + draft.rows[0].value = 'one m'; + await host.receive({ type: 'viewRefreshReady', session: draft.session, requestId: renewed.requestId, filterDraft: draft }); + assert.strictEqual(metadata(host).filterDraft.rows[0].value, 'one m'); + } + assert.strictEqual(host.document.version, 1); + assert.strictEqual(host.document.getText(), host.diskText); + } finally { release(); } + } + }); + + it('roundtrips only validated true IME markers without saving unfinished text', async () => { + const host = await createEditorHarness({ rows: [['Name'], ['one']], filters: [{ column: 0, operator: 'contains', value: 'one' }] }); + const stored = JSON.stringify(host.context.workspaceState.values); + const draft = draftFor(host); + draft.rows = [{ column: 0, operator: 'contains', value: 'unfinished IME', composing: true }, { column: 0, operator: 'regex', value: '[', composing: false }]; + const next = await acknowledge(host, draft); + assert.deepStrictEqual(next.filterDraft.rows, [{ column: 0, operator: 'contains', value: 'unfinished IME', composing: true }, { column: 0, operator: 'regex', value: '[' }]); + assert.strictEqual(JSON.stringify(host.context.workspaceState.values), stored); + assert.strictEqual(host.document.getText(), host.diskText); + for (const marker of ['true', 1, null, {}]) { + const invalid = draftFor(host); + invalid.rows[0].composing = marker; + assert.strictEqual((await acknowledge(host, invalid)).filterDraft, undefined); + assert.strictEqual(JSON.stringify(host.context.workspaceState.values), stored); + } + }); + + it('retains current intent after a newer valid filter was accepted in the same session', async () => { + const host = await createEditorHarness({ rows: [['Name'], ['one'], ['two']], filters: [{ column: 0, operator: 'contains', value: 'one' }] }); + const draft = draftFor(host, [{ column: 0, operator: 'contains', value: 'ch' }]); + draft.focus = { row: 0, field: 'value', selectionStart: 1, selectionEnd: 2, selectionDirection: 'none' }; + const accepted = await update(host, 1, [{ column: 0, operator: 'contains', value: 'two' }]); + assert.strictEqual(accepted.filterEpoch, 1); + const refreshed = await acknowledge(host, draft); + assert.strictEqual(refreshed.filters[0].value, 'two', 'last accepted conditions remain persisted'); + assert.strictEqual(refreshed.filterDraft.rows[0].value, 'ch', 'an undelivered response cannot replace the newer raw draft'); + assert.deepStrictEqual(refreshed.filterDraft.focus, draft.focus); + }); + + it('carries a pending Clear all as an empty draft without prematurely changing preferences', async () => { + const host = await createEditorHarness({ rows: [['Name'], ['one']], filters: [{ column: 0, operator: 'contains', value: 'one' }] }); + const cleared = draftFor(host, []); + const next = await acknowledge(host, cleared); + assert.deepStrictEqual(next.filterDraft.rows, []); + assert.strictEqual(saved(host).length, 1); + await update(host, 1, [], next.session); + assert.deepStrictEqual(saved(host), []); + assert.strictEqual(host.document.getText(), host.diskText); + }); + + it('rejects malformed, oversized and stale draft state without persisting any draft data', async () => { + const host = await createEditorHarness({ rows: [['Name'], ['one']] }); + const malformed: Array<(draft: any) => void> = [ + draft => { draft.session = 'previous-view'; }, + draft => { delete draft.filterColumnsRevision; }, + draft => { draft.filterColumnsRevision += 1; }, + draft => { draft.rows = {}; }, + draft => { draft.rows = Array.from({ length: metadata(host).maxFilters + 1 }, () => ({ column: 0, operator: 'contains', value: '' })); }, + draft => { draft.rows[0].column = 1; }, + draft => { draft.rows[0].column = '0'; }, + draft => { draft.rows[0].operator = 'unknown'; }, + draft => { draft.rows[0].value = 'x'.repeat(4097); }, + draft => { draft.columns[0].label = 'another column'; }, + draft => { draft.columns = []; }, + draft => { draft.focus = { row: 1, field: 'value' }; }, + draft => { draft.focus = { row: 0, field: 'unknown' }; }, + draft => { draft.focus = { row: 0, field: 'value', selectionStart: 0, selectionEnd: 100, selectionDirection: 'none' }; }, + draft => { draft.focus = { row: 0, field: 'value', selectionStart: 2, selectionEnd: 1, selectionDirection: 'none' }; }, + draft => { draft.focus = { row: 0, field: 'value', selectionStart: 0, selectionEnd: 1, selectionDirection: 'unknown' }; } + ]; + const stored = JSON.stringify(host.context.workspaceState.values); + for (const mutate of malformed) { + const draft = draftFor(host); + mutate(draft); + assert.strictEqual((await acknowledge(host, draft)).filterDraft, undefined); + assert.strictEqual(JSON.stringify(host.context.workspaceState.values), stored); + } + const oldSession = metadata(host).session; + await acknowledge(host, undefined); + const html = host.panel.webview.html; + await acknowledge(host, draftFor(host), oldSession); + assert.strictEqual(host.panel.webview.html, html, 'an old ACK cannot refresh the new view'); + }); + + it('invalidates duplicate-label column reordering while preserving no-op mapping revisions', async () => { + const host = await createEditorHarness({ rows: [['Same', 'Same'], ['left', 'right']] }); + const initial = metadata(host), stale = draftFor(host); + assert.strictEqual(initial.filterColumnsRevision, 0); + await host.receive({ type: 'reorderColumns', indices: [0], beforeIndex: 1 }); + assert.strictEqual(metadata(host).filterColumnsRevision, initial.filterColumnsRevision); + await host.receive({ type: 'reorderColumns', indices: [0], beforeIndex: 2 }); + const reordered = metadata(host); + assert.deepStrictEqual(reordered.columns.map((column: any) => column.label), ['Same', 'Same']); + assert.strictEqual(reordered.filterColumnsRevision, initial.filterColumnsRevision + 1); + assert.deepStrictEqual(host.rows()[1], ['right', 'left']); + stale.session = reordered.session; + assert.strictEqual((await acknowledge(host, stale)).filterDraft, undefined, 'identical labels do not authorize a draft from the old column mapping'); + await host.receive({ type: 'insertColumn', index: 1 }); + const inserted = metadata(host); + assert.strictEqual(inserted.filterColumnsRevision, reordered.filterColumnsRevision + 1); + await host.receive({ type: 'deleteColumn', index: 1 }); + assert.strictEqual(metadata(host).filterColumnsRevision, inserted.filterColumnsRevision + 1); + }); + + it('drops drafts when column labels change or the rendered owner URI has rebound', async () => { + const host = await createEditorHarness({ rows: [['Name'], ['one']] }); + const renamedDraft = draftFor(host); + await host.receive({ type: 'editCell', row: 0, col: 0, value: 'Renamed' }); + assert.strictEqual((await acknowledge(host, renamedDraft)).filterDraft, undefined, 'changed semantic labels invalidate the old draft'); + const reboundDraft = draftFor(host); + const OldUri = host.document.uri.constructor as new (path: string) => typeof host.document.uri; + host.document.uri = new OldUri('/test/rebound.csv'); + const rebound = await acknowledge(host, reboundDraft); + assert.strictEqual(rebound.filterDraft, undefined, 'a session belongs to its original URI even when the live owner changes before ACK'); + assert.strictEqual(host.rows()[0][0], 'Renamed'); + assert.ok(!host.diskText.includes('Renamed')); + }); +}); diff --git a/src/test/untitled-draft-close.test.ts b/src/test/untitled-draft-close.test.ts new file mode 100644 index 0000000..76152d5 --- /dev/null +++ b/src/test/untitled-draft-close.test.ts @@ -0,0 +1,53 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom, flushViewCommits } from './edit-integrity-harness'; + +describe('untitled grid closes after its final active draft commits', () => { + for (const separator of [',', '\t']) { + it(`preserves the active multiline draft when disabled (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ separator, untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('latest draft'); + host.deferEdits(); + host.revealSourceAfterCommit(); + const request = host.messages.find(message => message.type === 'prepareViewRefresh'); + assert.ok(request); + assert.strictEqual(host.panel.disposed, false); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + const operations = view.messages.filter(message => message.type === 'editCell' || message.type === 'viewRefreshReady'); + assert.deepStrictEqual(operations.map(message => message.type), ['editCell', 'viewRefreshReady']); + const pending = operations.map(message => host.receive(message)); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + assert.strictEqual(host.panel.disposed, false); + assert.strictEqual(host.sourceReveals.length, 0); + host.releaseEdit(); + await Promise.all(pending); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.sourceReveals[0], host.document.getText()); + assert.strictEqual(host.panel.disposed, true); + } finally { + view.close(); + } + }); + } + it('closes an unsupported-language table after a fresh draft ACK while CSV remains enabled', async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.cell().textContent = 'language change draft'; + host.rebindDocument('plaintext'); + host.activeController!.revealSourceAfterCommit('language'); + await flushViewCommits(host, view); + assert.strictEqual(host.rows()[1][0], 'language change draft'); + assert.strictEqual(host.panel.disposed, true); + assert.strictEqual(host.sourceReveals.at(-1), host.document.getText()); + assert.strictEqual(host.diskText.includes('language change draft'), false); + } finally { view.close(); host.dispose(); } + }); + +}); diff --git a/src/test/untitled-draft-lifecycle.test.ts b/src/test/untitled-draft-lifecycle.test.ts new file mode 100644 index 0000000..5238ca9 --- /dev/null +++ b/src/test/untitled-draft-lifecycle.test.ts @@ -0,0 +1,196 @@ +import assert from 'assert'; +import Papa from 'papaparse'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +const nextTurn = () => new Promise(resolve => setImmediate(resolve)); +function currentFilterSession(host: Awaited>): string { + const metadata = /