Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Working with CSV files shouldn’t be a chore. With CSV, you get:

## Features

- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs.
- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. If a cell edit fails, retry it or press Escape to discard its draft before changing or disabling the table view.
- **Table/Text Switching:** Use the switch action in the editor toolbar or `CSV: Switch Between Table and Text` in the Command Palette to move between views while retaining unsaved edits. The target opens before the original view is replaced, so one representation remains in that editor group. An active cell commits before switching, and unsaved document edits remain unsaved. Other tabs and groups are unaffected. A table already open for this file in another group must be switched from that group. For a saved local file, choosing Text also remembers a reversible file-path editor association, so Find in Files continues to open that file in text while CSV stays enabled. The choice survives a window restart and an extension Off/On cycle; a successful switch back to Table restores only that owned association. Other files continue to open as tables. File patterns follow VS Code’s case-insensitive matching. Remote/virtual URIs whose authority cannot be isolated still switch views, with guidance to turn CSV off before using Find in Files; longer user associations are preserved and reported if they prevent native routing. Searching a file that is still in table mode is outside this text-mode handoff. The table view requires `csv.enabled`. This action requires VS Code 1.100 or newer for clean documents and 1.120 or newer for unsaved documents; older hosts show update guidance. Existing extension features continue to support the declared VS Code minimum.
- **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.
Expand Down Expand Up @@ -82,6 +83,7 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102).

Open the Command Palette and search for:

- `CSV: Switch Between Table and Text` (`csv.toggleEditor`)
- `CSV: Toggle Extension On/Off` (`csv.toggleExtension`)
- `CSV: Toggle First Row as Header` (`csv.toggleHeader`)
- `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`)
Expand All @@ -97,7 +99,7 @@ Open the Command Palette and search for:

Global (Settings UI or `settings.json`):

- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. The existing Toggle Extension command honors an explicit workspace value, otherwise it changes the global value. While off, CSV temporarily routes its four file selectors and existing explicit CSV-editor associations to the native text editor, so Find in Files preserves the destination and selection. Re-enabling restores the previous associations. Other editors, unrelated associations, and intervening user changes are preserved. Routing recovery metadata is stored in VS Code extension state; it contains editor identifiers and patterns, never CSV data. If settings or policy prevent routing, CSV reports the failure instead of claiming the toggle succeeded.
- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. The existing Toggle Extension command honors an explicit workspace value, otherwise it changes the global value. While off, CSV temporarily routes its four file selectors and existing explicit CSV-editor associations to the native text editor, so Find in Files preserves the destination and selection. Re-enabling restores the previous disabled-state associations while retaining files explicitly switched to Text. Other editors, unrelated associations, and intervening user changes are preserved. Routing recovery metadata is stored in VS Code extension state; it contains editor identifiers and patterns, never CSV data. If settings or policy prevent routing, CSV reports the failure instead of claiming the toggle succeeded.
- `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.
Expand Down
33 changes: 30 additions & 3 deletions media/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ let lastContextIsHeader = false; // remembers whether we right-clicked a <th>
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
Expand Down Expand Up @@ -1819,7 +1821,17 @@ document.addEventListener('keydown', e => {
}
}
if (editingCell && e.key === 'Escape') {
e.preventDefault(); editingCell.textContent = 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();
Expand Down Expand Up @@ -1965,6 +1977,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');
Expand All @@ -1973,7 +1986,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;
Expand Down Expand Up @@ -2038,7 +2053,15 @@ const copySelectionToClipboard = () => {

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 });
} 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 (anchorCell) {
try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} }
} else {
Expand Down Expand Up @@ -2078,6 +2101,7 @@ 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 && cell !== editingCell) {
if (typeof rendered === 'string') {
Expand Down Expand Up @@ -2196,3 +2220,6 @@ document.addEventListener('keydown', e => {
clearSelection();
}
});

// Retry a host commit request that arrived before these listeners loaded.
vscode.postMessage({ type: 'webviewReady' });
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"onLanguage:csv",
"onLanguage:tsv",
"onCommand:csv.toggleExtension",
"onCommand:csv.toggleEditor",
"onCommand:csv.toggleHeader",
"onCommand:csv.toggleSerialIndex",
"onCommand:csv.changeSeparator",
Expand Down Expand Up @@ -65,6 +66,11 @@
}
],
"commands": [
{
"command": "csv.toggleEditor",
"title": "CSV: Switch Between Table and Text",
"icon": "$(arrow-swap)"
},
{
"command": "csv.toggleExtension",
"title": "CSV: Toggle Extension On/Off"
Expand Down Expand Up @@ -102,6 +108,15 @@
"title": "CSV: Toggle Clickable Links"
}
],
"menus": {
"editor/title": [
{
"command": "csv.toggleEditor",
"when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))",
"group": "navigation"
}
]
},
"configuration": {
"type": "object",
"title": "CSV Extension Configuration",
Expand Down
99 changes: 99 additions & 0 deletions scripts/editor-switch-smoke.cjs
Original file line number Diff line number Diff line change
@@ -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 }); }
};
Loading
Loading