Skip to content
Merged
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
200 changes: 41 additions & 159 deletions README.md

Large diffs are not rendered by default.

208 changes: 208 additions & 0 deletions docs/user-guide.md

Large diffs are not rendered by default.

Binary file added images/selection-statistics.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 53 additions & 2 deletions media/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,35 @@ window.addEventListener('wheel', handleZoomWheel, { passive: false });
let hasHeader = document.querySelector('thead') !== null;
let headerEnabled = root?.dataset?.headerenabled === '1';
const getCellCoords = cell => ({ row: parseInt(cell.getAttribute('data-row')), col: parseInt(cell.getAttribute('data-col')) });
const clearSelection = () => { currentSelection.forEach(c => c.classList.remove('selected')); currentSelection = []; };
let numericSelectionScope = null;
let selectionReportScheduled = false;
const scheduleSelectionReport = () => {
if (selectionReportScheduled) return;
selectionReportScheduled = true;
requestAnimationFrame(() => {
selectionReportScheduled = false;
let selection = editingCell ? null : numericSelectionScope;
if (!selection && !editingCell) {
let minRow = Infinity, maxRow = -1, minCol = Infinity, maxCol = -1;
for (const cell of currentSelection) {
if (!cell.classList.contains('selected')) continue;
const { row, col } = getCellCoords(cell);
if (row < 0 || col < 0) continue;
minRow = Math.min(minRow, row); maxRow = Math.max(maxRow, row);
minCol = Math.min(minCol, col); maxCol = Math.max(maxCol, col);
}
selection = maxRow >= 0 && maxCol >= 0 ? { kind: 'cells', minRow, maxRow, minCol, maxCol } : null;
}
vscode.postMessage({ type: 'selectionChanged', selection, session: filterSession, filterEpoch });
});
};
const clearSelection = () => {
currentSelection.forEach(c => c.classList.remove('selected'));
currentSelection = [];
numericSelectionScope = null;
// Read the final selection after synchronous mouse/keyboard handlers finish.
scheduleSelectionReport();
};
const contextMenu = document.getElementById('contextMenu');

/* ──────── UPDATED showContextMenu ──────── */
Expand Down Expand Up @@ -1163,6 +1191,7 @@ const selectRange = (start, end) => {
clearSelection();
const minRow = Math.min(start.row, end.row), maxRow = Math.max(start.row, end.row);
const minCol = Math.min(start.col, end.col), maxCol = Math.max(start.col, end.col);
numericSelectionScope = { kind: 'cells', minRow, maxRow, minCol: Math.max(0, minCol), maxCol };
const selectedRows = visibleRowOrder.length ? visibleRowOrder.filter(row => row >= minRow && row <= maxRow) : Array.from({ length: maxRow - minRow + 1 }, (_, index) => minRow + index);
for (const r of selectedRows) {
for(let c = minCol; c <= maxCol; c++){
Expand All @@ -1176,6 +1205,7 @@ const selectRange = (start, end) => {
const selectFullColumnRange = (col1, col2) => {
clearSelection();
const minCol = Math.min(col1, col2), maxCol = Math.max(col1, col2);
numericSelectionScope = { kind: 'columns', minCol: Math.max(0, minCol), maxCol };
table.querySelectorAll('tr').forEach(row => {
Array.from(row.children).forEach(cell => {
const cellCol = cell.getAttribute('data-col');
Expand All @@ -1189,6 +1219,7 @@ const selectFullColumnRange = (col1, col2) => {
const selectFullRowRange = (row1, row2) => {
clearSelection();
const minRow = Math.min(row1, row2), maxRow = Math.max(row1, row2);
numericSelectionScope = { kind: 'rows', minRow, maxRow };
table.querySelectorAll('tr').forEach(row => {
Array.from(row.children).forEach(cell => {
const r = cell.getAttribute('data-row');
Expand All @@ -1199,6 +1230,19 @@ const selectFullRowRange = (row1, row2) => {
});
};

// Newly loaded cells belong to an existing whole-column/table or range selection.
window.addEventListener('csvChunkLoaded', () => {
const scope = numericSelectionScope;
if (!scope || editingCell) return;
table.querySelectorAll('td:not(.selected), th:not(.selected)').forEach(cell => {
const { row, col } = getCellCoords(cell);
if ('minRow' in scope && (row < scope.minRow || row > scope.maxRow)) return;
if ('minCol' in scope && (col < scope.minCol || col > scope.maxCol)) return;
cell.classList.add('selected');
currentSelection.push(cell);
});
});

const extendSelectionTo = target => {
rangeEndCell = target;
const start = getCellCoords(anchorCell);
Expand Down Expand Up @@ -2075,7 +2119,11 @@ document.addEventListener('paste', e => {
});
});

const selectAllCells = () => { clearSelection(); document.querySelectorAll('td, th').forEach(cell => { cell.classList.add('selected'); currentSelection.push(cell); }); };
const selectAllCells = () => {
clearSelection();
numericSelectionScope = { kind: 'all' };
table.querySelectorAll('td, th').forEach(cell => { cell.classList.add('selected'); currentSelection.push(cell); });
};

const setCursorToEnd = cell => {
const range = document.createRange(); range.selectNodeContents(cell); range.collapse(false);
Expand Down Expand Up @@ -2184,6 +2232,8 @@ const editCell = (cell, event, mode = 'detail') => {
cell.classList.remove('selected');
originalCellValue = cell.textContent;
editingCell = cell;
numericSelectionScope = null;
scheduleSelectionReport();
vscode.postMessage({ type: 'cellEditStarted' });
editMode = mode;
cell.classList.add('editing');
Expand All @@ -2199,6 +2249,7 @@ const editCell = (cell, event, mode = 'detail') => {
cell.removeAttribute('contenteditable');
cell.classList.remove('editing');
editingCell = null;
scheduleSelectionReport();
editMode = null;
cell.removeEventListener('blur', onBlurHandler);
};
Expand Down
68 changes: 67 additions & 1 deletion src/CsvEditorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { parseClipboardMatrix, serializeClipboardMatrix } from './clipboard';
import { ColumnFilter, normalizeColumnFilters, compileColumnFilters, visibleSourceRows, remapColumnFilters, getFilterOperatorChoices, validateColumnFilterInput, MAX_COLUMN_FILTERS, MAX_FILTER_VALUE_LENGTH } from './column-filters';
import { FilterDataType, parseFilterDate } from './filter-values';
import { NumericSelection, computeSelectionStatistics, formatSelectionStatistics, parseNumericSelection } from './selection-statistics';

type FrozenPanes = { rows: number; columns: number };

Expand Down Expand Up @@ -121,6 +122,11 @@
private filterSessionUri = '';
private filterColumnsRevision = 0;
private pendingFilterDraft: { uri: string; draft: FilterDraft } | undefined;
private numericSelection: NumericSelection | undefined;
private selectionStatusBar: vscode.StatusBarItem | undefined;
private selectionStatusText: string | undefined;
private selectionStatisticsVersion = -1;
private selectionData: { version: number; data: string[][] } | undefined;

constructor(private readonly context: vscode.ExtensionContext) {}

Expand All @@ -138,6 +144,9 @@
// Register before remote stat/prompt awaits: a closed panel stays closed.
webviewPanel.onDidDispose(() => {
this.disposed = true;
this.selectionStatusBar?.dispose();
this.selectionStatusBar = undefined;
this.selectionData = undefined;
changeDocumentSubscription?.dispose();
saveDocumentSubscription?.dispose();
CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this);
Expand Down Expand Up @@ -183,6 +192,7 @@
if (webviewPanel.active) {
CsvEditorProvider.currentActive = this;
this.updateWordWrapContext();
this.updateSelectionStatusBar();
}
void this.warnAboutPossibleDecoding().catch(error => {
console.warn('CSV: Encoding warning action failed', error);
Expand All @@ -203,10 +213,12 @@
e.webviewPanel.webview.postMessage({ type: 'focus' });
CsvEditorProvider.currentActive = this;
this.updateWordWrapContext();
this.updateSelectionStatusBar();
} else if (CsvEditorProvider.currentActive === this) {
CsvEditorProvider.currentActive = undefined;
void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', false);
}
if (!e.webviewPanel.active) { this.selectionStatusBar?.hide(); }
});

webviewPanel.webview.onDidReceiveMessage(e => {
Expand Down Expand Up @@ -240,6 +252,12 @@
return;
}
switch (e.type) {
case 'selectionChanged':
if (e.session === this.filterSession && e.filterEpoch === this.filterEpoch) {
this.numericSelection = parseNumericSelection(e.selection);
this.refreshSelectionStatistics(true);
}
break;
case 'webviewReady': {
if (this.viewCommitRequested) {
this.postViewCommitRequest();
Expand Down Expand Up @@ -388,7 +406,10 @@
private enqueueOperation(operation: () => void | Promise<void>): Promise<void> {
// Event listeners are not awaited by VS Code. Serialize mutations, reads and
// saves so every command sees the result of the preceding document edit.
this.pendingOperation = this.pendingOperation.then(operation).catch(error => {
this.pendingOperation = this.pendingOperation.then(async () => {
await operation();
this.refreshSelectionStatistics();
}).catch(error => {
console.error('CSV: Editor operation failed', error);
});
return this.pendingOperation;
Expand Down Expand Up @@ -775,7 +796,7 @@
}

private forceReload() {
if (!this.currentWebviewPanel || this.failedCellEdits.size) return;

Check warning on line 799 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
const panel = this.currentWebviewPanel;
// First, blank the DOM to ensure a full script/style reinit on next set
panel.webview.html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body></body></html>';
Expand Down Expand Up @@ -955,6 +976,7 @@
this.currentFilterView = view;
this.currentHeader = view.headerEnabled;
this.filterEpoch = rawRevision;
this.resetSelectionStatistics(data);
reply({});
}

Expand Down Expand Up @@ -1038,6 +1060,49 @@
}
}

private resetSelectionStatistics(data: string[][]): void {
this.selectionData = { version: this.document.version, data };
this.numericSelection = undefined;
this.selectionStatusText = undefined;
this.selectionStatisticsVersion = -1;
this.selectionStatusBar?.hide();
}

private refreshSelectionStatistics(force = false): void {
if (this.disposed || (!force && this.selectionStatisticsVersion === this.document.version)) { return; }
this.selectionStatisticsVersion = this.document.version;
const view = this.currentFilterView;
if (!this.numericSelection || !view) {
this.selectionStatusText = undefined;
} else {
if (this.selectionData?.version !== this.document.version) {
this.selectionData = {
version: this.document.version,
data: this.trimTrailingEmptyRows(Papa.parse<string[]>(this.document.getText(), { delimiter: this.getSeparator() }).data)
};
}
const stats = computeSelectionStatistics(this.selectionData.data, this.numericSelection,
view.firstVisibleRow + (view.headerEnabled ? 1 : 0), view.filters.length ? view.visibleRowIds : undefined);
this.selectionStatusText = stats.count ? formatSelectionStatistics(stats) : undefined;
}
this.updateSelectionStatusBar();
}

private updateSelectionStatusBar(): void {
if (!this.isActive() || !this.selectionStatusText || this.disposed) {
this.selectionStatusBar?.hide();
return;
}
if (!this.selectionStatusBar) {
this.selectionStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.selectionStatusBar.name = 'CSV Numeric Selection';
this.selectionStatusBar.tooltip = 'Sum, average, and count of selected numeric data cells. Headers, hidden rows, blanks, and text are excluded.';
this.context.subscriptions.push(this.selectionStatusBar);
}
this.selectionStatusBar.text = this.selectionStatusText;
this.selectionStatusBar.show();
}

public setWordWrap(enabled: boolean): void {
void this.currentWebviewPanel?.webview.postMessage({ type: 'setWordWrap', enabled });
}
Expand Down Expand Up @@ -1574,7 +1639,7 @@
}

private buildFindRegex(query: string, options: { regex: boolean; wholeWord: boolean; matchCase: boolean }): RegExp | undefined {
if (!query) return undefined;

Check warning on line 1642 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
const useRegex = !!options.regex;
const wholeWord = !!options.wholeWord;
const matchCase = !!options.matchCase;
Expand Down Expand Up @@ -1667,17 +1732,17 @@
}
data[row][col] = '';
} else {
while (data.length <= row) data.push([]);

Check warning on line 1735 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
while (data[row].length <= col) data[row].push('');

Check warning on line 1736 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
data[row][col] = value;
}

let trimmed = false;
if (wasEditingLastRow) {
const isRowEmpty = (arr: string[] | undefined) => {
if (!arr || arr.length === 0) return true;

Check warning on line 1743 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
for (let i = 0; i < arr.length; i++) {
if ((arr[i] ?? '') !== '') return false;

Check warning on line 1745 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
}
return true;
};
Expand Down Expand Up @@ -1723,7 +1788,7 @@
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('');

Check warning on line 1791 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
}
row.splice(index, 0, '');
}
Expand All @@ -1744,7 +1809,7 @@
}

private async insertColumns(index: number, count: number) {
if (count <= 0) return;

Check warning on line 1812 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
this.isUpdatingDocument = true;
const separator = this.getSeparator();
const text = this.document.getText();
Expand All @@ -1754,7 +1819,7 @@
for (let k = 0; k < count; k++) {
for (const row of data) {
if (index > row.length) {
while (row.length < index) row.push('');

Check warning on line 1822 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
}
row.splice(index, 0, '');
}
Expand Down Expand Up @@ -1804,7 +1869,7 @@
}

private async deleteColumns(indices: number[]) {
if (!indices || !indices.length) return;

Check warning on line 1872 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
this.isUpdatingDocument = true;
const separator = this.getSeparator();
const text = this.document.getText();
Expand Down Expand Up @@ -2181,6 +2246,7 @@
this.filterEpoch = 0;
this.filterSession = this.getNonce();
this.filterSessionUri = this.document.uri.toString();
this.resetSelectionStatistics(data);
const mouseWheelZoomEnabled = config.get<boolean>('mouseWheelZoom', true);
const mouseWheelZoomInvert = config.get<boolean>('mouseWheelZoomInvert', false);
const { tableHtml, chunksJson, colorCss, nextChunkStart, hasRemoteChunks } = view;
Expand Down
83 changes: 83 additions & 0 deletions src/selection-statistics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { parseFilterNumber } from './filter-values';

export type NumericSelection =
| { kind: 'cells'; minRow: number; maxRow: number; minCol: number; maxCol: number }
| { kind: 'rows'; minRow: number; maxRow: number }
| { kind: 'columns'; minCol: number; maxCol: number }
| { kind: 'all' };

export interface SelectionStatistics {
sum: number;
average: number;
count: number;
}

/** Validate the compact selection message before using its bounds. */
export function parseNumericSelection(raw: unknown): NumericSelection | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return undefined; }
const value = raw as Record<string, unknown>;
const validRange = (min: unknown, max: unknown): boolean =>
typeof min === 'number' && typeof max === 'number' && Number.isSafeInteger(min) && Number.isSafeInteger(max) && min >= 0 && max >= min;
if (value.kind === 'all') { return { kind: 'all' }; }
if (value.kind === 'cells' || value.kind === 'rows') {
if (!validRange(value.minRow, value.maxRow)) { return undefined; }
if (value.kind === 'rows') { return { kind: 'rows', minRow: value.minRow as number, maxRow: value.maxRow as number }; }
}
if ((value.kind === 'cells' || value.kind === 'columns') && validRange(value.minCol, value.maxCol)) {
const columns = { minCol: value.minCol as number, maxCol: value.maxCol as number };
return value.kind === 'columns' ? { kind: 'columns', ...columns }
: { kind: 'cells', minRow: value.minRow as number, maxRow: value.maxRow as number, ...columns };
}
return undefined;
}

/** Aggregate source values, including unloaded rows, within the displayed selection. */
export function computeSelectionStatistics(
data: readonly (readonly string[])[],
selection: NumericSelection,
firstDataRow: number,
visibleRowIds?: readonly number[]
): SelectionStatistics {
const minRow = Math.max(firstDataRow, 'minRow' in selection ? selection.minRow : 0);
const maxRow = Math.min(data.length - 1, 'maxRow' in selection ? selection.maxRow : data.length - 1);
const minCol = 'minCol' in selection ? selection.minCol : 0;
const maxCol = 'maxCol' in selection ? selection.maxCol : Infinity;
let count = 0, sum = 0, correction = 0, scale = 0;
const addRow = (rowId: number) => {
if (rowId < minRow || rowId > maxRow) { return; }
const row = data[rowId];
for (let col = minCol; col <= Math.min(maxCol, row.length - 1); col++) {
const value = parseFilterNumber(row[col]);
if (value === undefined) { continue; }
count++;
// Scale before compensated summation so intermediate totals cannot overflow.
// This also keeps the average finite when a valid sum exceeds Number.MAX_VALUE.
const magnitude = Math.abs(value);
if (magnitude > scale) {
const ratio = scale / magnitude;
sum *= ratio;
correction *= ratio;
scale = magnitude;
}
const normalized = scale ? value / scale : 0;
const next = sum + normalized;
correction += Math.abs(sum) >= Math.abs(normalized) ? (sum - next) + normalized : (normalized - next) + sum;
sum = next;
}
};
if (visibleRowIds) {
for (const rowId of visibleRowIds) { addRow(rowId); }
} else {
for (let rowId = minRow; rowId <= maxRow; rowId++) { addRow(rowId); }
}
const total = sum + correction;
return { sum: total * scale, average: count ? (total / count) * scale : 0, count };
}

export function formatSelectionStatistics(stats: SelectionStatistics): string {
const format = (value: number) => {
if (!Number.isFinite(value)) { return 'Overflow'; }
return Number.isSafeInteger(value) ? String(value) : String(Number(value.toPrecision(15)));
};
return `Sum: ${format(stats.sum)} Average: ${format(stats.average)} Count: ${stats.count}`;
}
8 changes: 4 additions & 4 deletions src/test/default-ignore-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ describe('Default hidden rows', () => {
assert.strictEqual(setting.default, 0);
assert.strictEqual(setting.minimum, 0);
assert.strictEqual(setting.scope, 'resource');
const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8');
assert.ok(readme.includes('csv.defaultIgnoreRows'));
assert.ok(readme.includes('blank'));
assert.ok(readme.includes('explicit `0`'));
const guide = fs.readFileSync(path.join(process.cwd(), 'docs', 'user-guide.md'), 'utf8');
assert.ok(guide.includes('csv.defaultIgnoreRows'));
assert.ok(guide.includes('blank'));
assert.ok(guide.includes('explicit `0`'));
});
});
9 changes: 9 additions & 0 deletions src/test/edit-integrity-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ const vscodeStub = {
replace(_uri: unknown, _range: unknown, text: string) { this.text = text; }
},
ColorThemeKind: { Dark: 1 },
StatusBarAlignment: { Right: 2 },
window: {
createStatusBarItem: () => {
const item = { text: '', name: '', tooltip: '', visible: false, disposed: false,
show() { this.visible = true; }, hide() { this.visible = false; },
dispose() { this.disposed = true; this.visible = false; } };
activeHarness.statusBarItems.push(item);
return item;
},
activeColorTheme: { kind: 1 },
onDidChangeActiveTextEditor: () => ({ dispose() {} }),
showTextDocument: async (document: any) => {
Expand Down Expand Up @@ -255,6 +263,7 @@ export async function createEditorHarness(options: {
commands: new Map<string, (...args: any[]) => unknown>(),
executedCommands: [] as Array<{ name: string; args: unknown[] }>,
contextUpdates: [] as unknown[][],
statusBarItems: [] as Array<{ text: string; name: string; tooltip: string; visible: boolean; disposed: boolean }>,
registerCommands() {
const { registerCsvCommands } = require('../commands') as typeof import('../commands');
registerCsvCommands(context as any);
Expand Down
6 changes: 3 additions & 3 deletions src/test/editor-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,9 @@ describe('single-view CSV table/text switching', () => {
assert.ok(action.when.includes('!isInDiffEditor'));
assert.ok(action.when.includes('resourceScheme != untitled'));
assert.ok(!action.when.includes('workbench.editors.textEditor'));
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
assert.ok(!readme.includes('Both representations may remain open'));
assert.ok(readme.includes('one representation remains'));
const guide = fs.readFileSync(path.join(root, 'docs', 'user-guide.md'), 'utf8');
assert.ok(!guide.includes('Both representations may remain open'));
assert.ok(guide.includes('one representation remains'));
});
});

Expand Down
Loading
Loading