From eb0a997a7f7072929adaf30b776270f938ee6f0b Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Mon, 20 Jul 2026 14:23:59 +0530 Subject: [PATCH 1/2] fix: add column drag and header sorting --- README.md | 3 +- media/main.js | 75 +++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- src/CsvEditorProvider.ts | 3 + src/test/webview-reorder-interactions.test.ts | 28 ++++++- 6 files changed, 102 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7dec9d6..807077a 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **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. +- **Drag to Reorder Columns:** Drag a column header to move it; an insertion line previews where the column will be placed. - **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. +- **Column Sorting:** Click a header to cycle through ascending, descending, and no sort indicator. The single arrow beside the header shows the active direction; double-click still edits the column name. You can also choose A–Z or Z–A from the header context menu. - **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). diff --git a/media/main.js b/media/main.js index 58079a4..2dc324b 100644 --- a/media/main.js +++ b/media/main.js @@ -36,6 +36,7 @@ const DRAG_THRESHOLD_PX = 4; const RESIZE_HANDLE_PX = 6; let resizeState = null; let reorderState = null; +let pendingHeaderSortTimer = null; const table = document.querySelector('#csv-root table'); const scrollContainer = document.querySelector('.table-container'); @@ -371,6 +372,48 @@ const hasHeader = document.querySelector('thead') !== null; 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'); +const renderSortIndicator = () => { + table.querySelectorAll('.sort-indicator').forEach(indicator => indicator.remove()); + table.querySelectorAll('thead th[data-col]').forEach(header => { + header.removeAttribute('aria-sort'); + }); + const st = vscode.getState() || {}; + if (!Number.isInteger(st.sortColumn) || typeof st.sortAscending !== 'boolean') return; + const header = table.querySelector(`thead th[data-col="${st.sortColumn}"]`); + if (!header) return; + header.setAttribute('aria-sort', st.sortAscending ? 'ascending' : 'descending'); +}; +const requestColumnSort = (col, ascending) => { + const st = vscode.getState() || {}; + vscode.setState({ ...st, sortColumn: col, sortAscending: ascending }); + renderSortIndicator(); + vscode.postMessage({ type: 'sortColumn', index: col, ascending }); +}; +const clearColumnSortIndicator = () => { + const st = vscode.getState() || {}; + vscode.setState({ ...st, sortColumn: undefined, sortAscending: undefined }); + renderSortIndicator(); +}; +const cancelPendingHeaderSort = () => { + if (pendingHeaderSortTimer === null) return; + clearTimeout(pendingHeaderSortTimer); + pendingHeaderSortTimer = null; +}; +const scheduleHeaderSort = col => { + cancelPendingHeaderSort(); + pendingHeaderSortTimer = setTimeout(() => { + pendingHeaderSortTimer = null; + const st = vscode.getState() || {}; + if (st.sortColumn !== col || typeof st.sortAscending !== 'boolean') { + requestColumnSort(col, true); + } else if (st.sortAscending) { + requestColumnSort(col, false); + } else { + clearColumnSortIndicator(); + } + }, 250); +}; +renderSortIndicator(); /* ──────── UPDATED showContextMenu ──────── */ const showContextMenu = (x, y, row, col) => { @@ -401,9 +444,9 @@ const showContextMenu = (x, y, row, col) => { /* Header-only: SORT functionality */ if (lastContextIsHeader) { item('Sort: A-Z', () => - vscode.postMessage({ type: 'sortColumn', index: col, ascending: true })); + requestColumnSort(col, true)); item('Sort: Z-A', () => - vscode.postMessage({ type: 'sortColumn', index: col, ascending: false })); + requestColumnSort(col, false)); } /* Row section */ @@ -629,17 +672,24 @@ const startResizeDrag = (target, e) => { }; const startReorderDrag = (target, e) => { if (e.button !== 0) return false; - if (isColumnHeaderCell(target) && target.classList.contains('selected')) { + if (isColumnHeaderCell(target)) { const col = parseInt(target.getAttribute('data-col') || 'NaN', 10); if (Number.isNaN(col)) return false; - const selected = getSelectedColumnIds(); - const indices = selected.includes(col) ? selected : [col]; + let indices = getSelectedColumnIds(); + if (!target.classList.contains('selected')) { + selectFullColumnRange(col, col); + anchorCell = target; + rangeEndCell = target; + indices = [col]; + } + if (!indices.includes(col)) indices = [col]; reorderState = { axis: 'column', indices, startX: e.clientX, startY: e.clientY, active: false, + sourceIndex: col, beforeIndex: null }; return true; @@ -684,6 +734,7 @@ const onGlobalDragMove = e => { e.preventDefault(); if (reorderState.axis === 'column') { + table.style.cursor = 'grabbing'; const target = getColumnDropTarget(e.clientX); if (!target) return; reorderState.beforeIndex = target.beforeIndex; @@ -703,13 +754,22 @@ const onGlobalDragEnd = () => { } if (!reorderState) return; - const { axis, indices, active, beforeIndex } = reorderState; + const { axis, indices, active, sourceIndex, beforeIndex } = reorderState; reorderState = null; hideDragIndicator(); table.style.cursor = ''; - if (!active || !Number.isFinite(beforeIndex)) return; + if (!active) { + if (axis === 'column') { + scheduleHeaderSort(sourceIndex); + } + persistState(); + return; + } + if (!Number.isFinite(beforeIndex)) return; if (axis === 'column') { + cancelPendingHeaderSort(); + clearColumnSortIndicator(); vscode.postMessage({ type: 'reorderColumns', indices, beforeIndex }); } else { vscode.postMessage({ type: 'reorderRows', indices, beforeIndex }); @@ -1989,6 +2049,7 @@ const editCell = (cell, event, mode = 'detail') => { }; table.addEventListener('dblclick', e => { + cancelPendingHeaderSort(); const edgeTarget = getCellTarget(e.target); const edge = getResizeEdgeInfo(edgeTarget, e); if (edge) { diff --git a/package-lock.json b/package-lock.json index fe4da3a..b26a5ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "csv", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "csv", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "font-list": "^1.5.1", "papaparse": "^5.5.3" diff --git a/package.json b/package.json index 31d2bda..0347fc4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "git", "url": "https://github.com/jonaraphael/csv.git" }, - "version": "1.3.0", + "version": "1.3.1", "engines": { "vscode": "^1.70.0", "node": ">=14" diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..5e6eba9 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -1706,6 +1706,9 @@ class CsvEditorController { table { border-collapse: collapse; width: max-content; } th, td { padding: ${cellPadding}px 8px; border: 1px solid ${isDark ? '#555' : '#ccc'}; font-size: inherit; } th { position: sticky; top: 0; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + thead th[data-col] { cursor: grab; } + thead th[aria-sort="ascending"]::after { content: " ↑"; font-weight: 700; } + thead th[aria-sort="descending"]::after { content: " ↓"; font-weight: 700; } td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; } td.selected, th.selected { background-color: ${isDark ? '#333333' : '#cce0ff'} !important; } td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; } diff --git a/src/test/webview-reorder-interactions.test.ts b/src/test/webview-reorder-interactions.test.ts index b136893..6ce7371 100644 --- a/src/test/webview-reorder-interactions.test.ts +++ b/src/test/webview-reorder-interactions.test.ts @@ -5,10 +5,12 @@ import path from 'path'; describe('Webview reorder and resize interactions', () => { const source = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'); + const providerSource = fs.readFileSync(path.join(process.cwd(), 'src', 'CsvEditorProvider.ts'), 'utf8'); - it('starts reorder only from preselected header or row-index cells', () => { + it('starts column reorder directly from any column header', () => { assert.ok(source.includes('startReorderDrag')); - assert.ok(source.includes('target.classList.contains(\'selected\')')); + assert.ok(source.includes('if (isColumnHeaderCell(target))')); + assert.ok(source.includes('selectFullColumnRange(col, col)')); assert.ok(source.includes('!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && startReorderDrag(target, e)')); }); @@ -17,6 +19,28 @@ describe('Webview reorder and resize interactions', () => { assert.ok(source.includes("type: 'reorderRows'")); }); + it('shows a vertical insertion guideline while dragging a column', () => { + assert.ok(source.includes('showColumnDropIndicator(target.indicatorX)')); + assert.ok(source.includes("dragIndicator.style.width = '2px'")); + assert.ok(source.includes("dragIndicator.style.display = 'block'")); + }); + + it('cycles header clicks through no sort, ascending, and descending', () => { + assert.ok(source.includes('scheduleHeaderSort(sourceIndex)')); + assert.ok(source.includes('requestColumnSort(col, true)')); + assert.ok(source.includes('requestColumnSort(col, false)')); + assert.ok(source.includes('clearColumnSortIndicator()')); + assert.ok(source.includes("header.setAttribute('aria-sort', st.sortAscending ? 'ascending' : 'descending')")); + }); + + it('renders one copy-safe arrow and keeps double-click available for header editing', () => { + assert.ok(source.includes("table.querySelectorAll('.sort-indicator').forEach(indicator => indicator.remove())")); + assert.ok(providerSource.includes('th[aria-sort="ascending"]::after { content: " ↑"')); + assert.ok(providerSource.includes('th[aria-sort="descending"]::after { content: " ↓"')); + assert.ok(source.includes("table.addEventListener('dblclick', e => {\n cancelPendingHeaderSort()")); + assert.ok(!source.includes("indicator.textContent = st.sortAscending")); + }); + it('supports drag-resize for columns and rows', () => { assert.ok(source.includes('startResizeDrag')); assert.ok(source.includes('col-resize')); From ff93f6fa355c5dc17b16f17b839157a02ebb548c Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Mon, 20 Jul 2026 14:44:26 +0530 Subject: [PATCH 2/2] feat: add chained column sorting --- README.md | 2 +- media/main.js | 101 +++++-- src/CsvEditorProvider.ts | 269 ++++++++++-------- src/test/sort-multiple-columns.test.ts | 116 ++++++++ src/test/webview-reorder-interactions.test.ts | 34 ++- 5 files changed, 365 insertions(+), 157 deletions(-) create mode 100644 src/test/sort-multiple-columns.test.ts diff --git a/README.md b/README.md index 807077a..f47dbd0 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Drag to Reorder Columns:** Drag a column header to move it; an insertion line previews where the column will be placed. - **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:** Click a header to cycle through ascending, descending, and no sort indicator. The single arrow beside the header shows the active direction; double-click still edits the column name. You can also choose A–Z or Z–A from the header context menu. +- **Chained Column Sorting:** Click headers to build an ordered multi-column sort, like pandas `sort_values(by=[...])`. Each header cycles through ascending, descending, and removal from the chain; its arrow and number show direction and priority. Empty values remain last, and clearing the chain restores the pre-sort row order. Double-click still edits the column name. - **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). diff --git a/media/main.js b/media/main.js index 2dc324b..a3c2c56 100644 --- a/media/main.js +++ b/media/main.js @@ -37,6 +37,8 @@ const RESIZE_HANDLE_PX = 6; let resizeState = null; let reorderState = null; let pendingHeaderSortTimer = null; +let pendingHeaderSortColumn = null; +let pendingHeaderSortInitial = null; const table = document.querySelector('#csv-root table'); const scrollContainer = document.querySelector('.table-container'); @@ -372,45 +374,100 @@ const hasHeader = document.querySelector('thead') !== null; 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'); +const getColumnSorts = () => { + const st = vscode.getState() || {}; + const raw = Array.isArray(st.columnSorts) + ? st.columnSorts + : (Number.isInteger(st.sortColumn) && typeof st.sortAscending === 'boolean' + ? [{ index: st.sortColumn, ascending: st.sortAscending }] + : []); + const seen = new Set(); + return raw.filter(sort => { + if (!sort || !Number.isInteger(sort.index) || sort.index < 0 || typeof sort.ascending !== 'boolean' || seen.has(sort.index)) { + return false; + } + seen.add(sort.index); + return true; + }).map(sort => ({ index: sort.index, ascending: sort.ascending })); +}; +const setColumnSorts = sorts => { + const st = vscode.getState() || {}; + vscode.setState({ ...st, columnSorts: sorts, sortColumn: undefined, sortAscending: undefined }); +}; const renderSortIndicator = () => { table.querySelectorAll('.sort-indicator').forEach(indicator => indicator.remove()); table.querySelectorAll('thead th[data-col]').forEach(header => { header.removeAttribute('aria-sort'); + header.removeAttribute('data-sort-priority'); + }); + getColumnSorts().forEach((sort, priority) => { + const header = table.querySelector(`thead th[data-col="${sort.index}"]`); + if (!header) return; + header.setAttribute('aria-sort', sort.ascending ? 'ascending' : 'descending'); + header.setAttribute('data-sort-priority', String(priority + 1)); }); - const st = vscode.getState() || {}; - if (!Number.isInteger(st.sortColumn) || typeof st.sortAscending !== 'boolean') return; - const header = table.querySelector(`thead th[data-col="${st.sortColumn}"]`); - if (!header) return; - header.setAttribute('aria-sort', st.sortAscending ? 'ascending' : 'descending'); }; -const requestColumnSort = (col, ascending) => { - const st = vscode.getState() || {}; - vscode.setState({ ...st, sortColumn: col, sortAscending: ascending }); +const applyColumnSorts = sorts => { + setColumnSorts(sorts); renderSortIndicator(); - vscode.postMessage({ type: 'sortColumn', index: col, ascending }); + vscode.postMessage({ type: 'sortColumns', sorts }); +}; +const requestColumnSort = (col, ascending) => { + cancelPendingHeaderSort(); + const sorts = getColumnSorts(); + const existing = sorts.find(sort => sort.index === col); + if (existing) { + existing.ascending = ascending; + } else { + sorts.push({ index: col, ascending }); + } + applyColumnSorts(sorts); }; const clearColumnSortIndicator = () => { - const st = vscode.getState() || {}; - vscode.setState({ ...st, sortColumn: undefined, sortAscending: undefined }); + setColumnSorts([]); renderSortIndicator(); }; -const cancelPendingHeaderSort = () => { +const cancelPendingHeaderSort = (restoreInitial = false) => { if (pendingHeaderSortTimer === null) return; clearTimeout(pendingHeaderSortTimer); + if (restoreInitial && pendingHeaderSortInitial) { + setColumnSorts(pendingHeaderSortInitial); + renderSortIndicator(); + } pendingHeaderSortTimer = null; + pendingHeaderSortColumn = null; + pendingHeaderSortInitial = null; +}; +const cycleColumnSortState = col => { + const sorts = getColumnSorts(); + const existing = sorts.find(sort => sort.index === col); + if (!existing) { + sorts.push({ index: col, ascending: true }); + } else if (existing.ascending) { + existing.ascending = false; + } else { + sorts.splice(sorts.indexOf(existing), 1); + } + setColumnSorts(sorts); + renderSortIndicator(); }; const scheduleHeaderSort = col => { - cancelPendingHeaderSort(); + if (pendingHeaderSortTimer !== null && pendingHeaderSortColumn === col) { + clearTimeout(pendingHeaderSortTimer); + } else { + if (pendingHeaderSortTimer !== null) { + clearTimeout(pendingHeaderSortTimer); + } + pendingHeaderSortInitial = getColumnSorts(); + pendingHeaderSortColumn = col; + cycleColumnSortState(col); + } pendingHeaderSortTimer = setTimeout(() => { + const sorts = getColumnSorts(); pendingHeaderSortTimer = null; - const st = vscode.getState() || {}; - if (st.sortColumn !== col || typeof st.sortAscending !== 'boolean') { - requestColumnSort(col, true); - } else if (st.sortAscending) { - requestColumnSort(col, false); - } else { - clearColumnSortIndicator(); - } + pendingHeaderSortColumn = null; + pendingHeaderSortInitial = null; + vscode.postMessage({ type: 'sortColumns', sorts }); }, 250); }; renderSortIndicator(); @@ -2049,7 +2106,7 @@ const editCell = (cell, event, mode = 'detail') => { }; table.addEventListener('dblclick', e => { - cancelPendingHeaderSort(); + cancelPendingHeaderSort(true); const edgeTarget = getCellTarget(e.target); const edge = getResizeEdgeInfo(edgeTarget, e); if (edge) { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 5e6eba9..186c161 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -69,6 +69,7 @@ class CsvEditorController { private separatorCache: { version: number; configKey: string; separator: string } | undefined; private isDiffContext = false; private chunkRenderState: ChunkRenderState | undefined; + private sortBaselineText: string | undefined; constructor(private readonly context: vscode.ExtensionContext) {} @@ -122,6 +123,14 @@ class CsvEditorController { }); webviewPanel.webview.onDidReceiveMessage(async e => { + if ([ + 'editCell', 'replaceCells', 'pasteCells', + 'insertColumn', 'insertColumns', 'deleteColumn', 'deleteColumns', + 'insertRow', 'insertRows', 'deleteRow', 'deleteRows', + 'reorderColumns', 'reorderRows' + ].includes(e.type)) { + this.sortBaselineText = undefined; + } switch (e.type) { case 'editCell': this.updateDocument(e.row, e.col, e.value); @@ -178,6 +187,9 @@ class CsvEditorController { case 'sortColumn': await this.sortColumn(e.index, e.ascending); break; + case 'sortColumns': + await this.sortColumns(e.sorts); + break; case 'openLink': await this.openLinkExternally(e.url); break; @@ -190,6 +202,7 @@ class CsvEditorController { !this.isUpdatingDocument && !this.isSaving ) { + this.sortBaselineText = undefined; setTimeout(() => this.updateWebviewContent(), 250); } }); @@ -1063,87 +1076,138 @@ class CsvEditorController { this.updateWebviewContent(); } - private async sortColumn(index: number, ascending: boolean) { - this.isUpdatingDocument = true; - - const config = vscode.workspace.getConfiguration('csv', this.document.uri); - const separator = this.getSeparator(); - const hidden = this.getHiddenRows(); + private compareSortValues(a: string, b: string, ascending: boolean): number { + const sa = (a ?? '').trim(); + const sb = (b ?? '').trim(); + const aEmpty = sa === ''; + const bEmpty = sb === ''; + if (aEmpty && bEmpty) { + return 0; + } + if (aEmpty) { + return 1; + } + if (bEmpty) { + return -1; + } - const text = this.document.getText(); - const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - // Exclude virtual/trailing empty rows from sort input - const rows = this.trimTrailingEmptyRows(result.data as string[][]); - const treatHeader = this.getEffectiveHeader(rows, this.getHiddenRows()); + if (this.isDate(sa) && this.isDate(sb)) { + const da = Date.parse(sa); + const db = Date.parse(sb); + if (!isNaN(da) && !isNaN(db)) { + return ascending ? da - db : db - da; + } + } - const offset = Math.min(Math.max(0, hidden), rows.length); - let header: string[] = []; - let body: string[][] = []; + const na = parseFloat(sa), nb = parseFloat(sb); + if (!isNaN(na) && !isNaN(nb)) { + return ascending ? na - nb : nb - na; + } + const diff = sa.localeCompare(sb, undefined, { sensitivity: 'base' }); + return ascending ? diff : -diff; + } - if (treatHeader && offset < rows.length) { - header = rows[offset]; - body = rows.slice(offset + 1); - } else { - body = rows.slice(offset); - } - - const cmp = (a: string, b: string) => { - const sa = (a ?? '').trim(); - const sb = (b ?? '').trim(); - const aEmpty = sa === ''; - const bEmpty = sb === ''; - if (aEmpty && bEmpty) return 0; - if (aEmpty) return 1; // empty sorts last - if (bEmpty) return -1; - - // Dates take precedence over numeric compare (avoid parseFloat on ISO) - const aIsDate = this.isDate(sa); - const bIsDate = this.isDate(sb); - if (aIsDate && bIsDate) { - const da = Date.parse(sa); - const db = Date.parse(sb); - if (!isNaN(da) && !isNaN(db)) return da - db; - } - - const na = parseFloat(sa), nb = parseFloat(sb); - if (!isNaN(na) && !isNaN(nb)) return na - nb; - return sa.localeCompare(sb, undefined, { sensitivity: 'base' }); - }; + private sortDataByColumns( + inputRows: string[][], + sorts: Array<{ index: number; ascending: boolean }>, + treatHeader: boolean, + hiddenRows: number + ): string[][] { + const rows = this.trimTrailingEmptyRows(inputRows); + const offset = Math.min(Math.max(0, hiddenRows), rows.length); + const header = treatHeader && offset < rows.length ? rows[offset] : []; + const body = rows.slice(treatHeader && offset < rows.length ? offset + 1 : offset); body.sort((r1, r2) => { - const diff = cmp(r1[index] ?? '', r2[index] ?? ''); - return ascending ? diff : -diff; + for (const sort of sorts) { + const diff = this.compareSortValues(r1[sort.index] ?? '', r2[sort.index] ?? '', sort.ascending); + if (diff !== 0) { + return diff; + } + } + return 0; }); 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); + return combined.map(row => row.map((value: any) => { + if (value === undefined || value === null) { + return ''; } - const s = String(v); - return s.toLowerCase() === 'nan' ? '' : s; + if (typeof value === 'number') { + return Number.isNaN(value) ? '' : String(value); + } + const text = String(value); + return text.toLowerCase() === 'nan' ? '' : text; })); + } - const newCsv = Papa.unparse(sanitized, { delimiter: separator }); + private async sortColumn(index: number, ascending: boolean) { + await this.sortColumns([{ index, ascending }]); + } - const fullRange = new vscode.Range( - 0, 0, - this.document.lineCount, - this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0 - ); + private async sortColumns(rawSorts: unknown) { + const sorts: Array<{ index: number; ascending: boolean }> = []; + const seen = new Set(); + if (Array.isArray(rawSorts)) { + for (const raw of rawSorts) { + const index = Number(raw?.index); + if (!Number.isInteger(index) || index < 0 || typeof raw?.ascending !== 'boolean' || seen.has(index)) { + continue; + } + seen.add(index); + sorts.push({ index, ascending: raw.ascending }); + } + } + this.isUpdatingDocument = true; + try { + const separator = this.getSeparator(); + const hiddenRows = this.getHiddenRows(); + if (!sorts.length) { + if (this.sortBaselineText === undefined) { + return; + } + const originalText = this.sortBaselineText; + const fullRange = new vscode.Range( + 0, 0, + this.document.lineCount, + this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0 + ); + const edit = new vscode.WorkspaceEdit(); + edit.replace(this.document.uri, fullRange, originalText); + await vscode.workspace.applyEdit(edit); + this.sortBaselineText = undefined; + this.updateWebviewContent(); + return; + } - const edit = new vscode.WorkspaceEdit(); - edit.replace(this.document.uri, fullRange, newCsv); - await vscode.workspace.applyEdit(edit); + if (this.sortBaselineText === undefined) { + this.sortBaselineText = this.document.getText(); + } + const result = Papa.parse(this.sortBaselineText, { dynamicTyping: false, delimiter: separator }); + const rows = this.trimTrailingEmptyRows(result.data as string[][]); + const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0); + const validSorts = sorts.filter(sort => sort.index < maxColumns); + if (!validSorts.length) { + return; + } - this.isUpdatingDocument = false; - this.updateWebviewContent(); - console.log(`CSV: Sorted column ${index + 1} (${ascending ? 'A-Z' : 'Z-A'})`); + const treatHeader = this.getEffectiveHeader(rows, hiddenRows); + const sorted = this.sortDataByColumns(rows, validSorts, treatHeader, hiddenRows); + const newCsv = Papa.unparse(sorted, { delimiter: separator }); + const fullRange = new vscode.Range( + 0, 0, + this.document.lineCount, + this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0 + ); + const edit = new vscode.WorkspaceEdit(); + edit.replace(this.document.uri, fullRange, newCsv); + await vscode.workspace.applyEdit(edit); + this.updateWebviewContent(); + console.log(`CSV: Sorted by ${validSorts.map(sort => `${sort.index + 1} ${sort.ascending ? 'A-Z' : 'Z-A'}`).join(', ')}`); + } finally { + this.isUpdatingDocument = false; + } } private async insertRow(index: number) { @@ -1707,8 +1771,8 @@ class CsvEditorController { th, td { padding: ${cellPadding}px 8px; border: 1px solid ${isDark ? '#555' : '#ccc'}; font-size: inherit; } th { position: sticky; top: 0; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } thead th[data-col] { cursor: grab; } - thead th[aria-sort="ascending"]::after { content: " ↑"; font-weight: 700; } - thead th[aria-sort="descending"]::after { content: " ↓"; font-weight: 700; } + thead th[aria-sort="ascending"]::after { content: " ↑" attr(data-sort-priority); font-weight: 700; } + thead th[aria-sort="descending"]::after { content: " ↓" attr(data-sort-priority); font-weight: 700; } td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; } td.selected, th.selected { background-color: ${isDark ? '#333333' : '#cce0ff'} !important; } td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; } @@ -2590,64 +2654,17 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static __test = { // Pure helper mirroring sort behavior; returns combined rows after sort. sortByColumn(rows: string[][], index: number, ascending: boolean, treatHeader: boolean, hiddenRows: number): string[][] { - // Trim trailing empty rows like runtime before sorting - const isEmpty = (r: string[] | undefined) => { - if (!r || r.length === 0) return true; - for (let i = 0; i < r.length; i++) { if ((r[i] ?? '') !== '') return false; } - return true; - }; - let end = rows.length; - while (end > 0 && isEmpty(rows[end - 1])) { end--; } - const trimmed = rows.slice(0, end); - - const offset = Math.min(Math.max(0, hiddenRows), trimmed.length); - let header: string[] = []; - let body: string[][] = []; - if (treatHeader && offset < trimmed.length) { - header = trimmed[offset]; - body = trimmed.slice(offset + 1); - } else { - body = trimmed.slice(offset); - } - const isDateStr = (v: string) => { - const s = (v ?? '').trim(); - if (!s) return false; - const isoDate = /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)?$/; - const isoSlash = /^\d{4}\/\d{2}\/\d{2}$/; - return isoDate.test(s) || isoSlash.test(s); - }; - const cmp = (a: string, b: string) => { - const sa = (a ?? '').trim(); - const sb = (b ?? '').trim(); - const aEmpty = sa === ''; - const bEmpty = sb === ''; - if (aEmpty && bEmpty) return 0; - if (aEmpty) return 1; // empty sorts last - if (bEmpty) return -1; - if (isDateStr(sa) && isDateStr(sb)) { - const da = Date.parse(sa); - const db = Date.parse(sb); - if (!isNaN(da) && !isNaN(db)) return da - db; - } - const na = parseFloat(sa), nb = parseFloat(sb); - if (!isNaN(na) && !isNaN(nb)) return na - nb; - return sa.localeCompare(sb, undefined, { sensitivity: 'base' }); - }; - body.sort((r1, r2) => { - const diff = cmp(r1[index] ?? '', r2[index] ?? ''); - return ascending ? diff : -diff; - }); - const prefix = trimmed.slice(0, offset); - - // Apply same sanitation used before unparse in runtime path - const combined = (treatHeader ? [...prefix, header, ...body] : [...prefix, ...body]).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; - })); - return combined; + const c: any = new (CsvEditorController as any)({} as any); + return c.sortDataByColumns(rows, [{ index, ascending }], treatHeader, hiddenRows); + }, + sortByColumns( + rows: string[][], + sorts: Array<{ index: number; ascending: boolean }>, + treatHeader: boolean, + hiddenRows: number + ): string[][] { + const c: any = new (CsvEditorController as any)({} as any); + return c.sortDataByColumns(rows, sorts, treatHeader, hiddenRows); }, computeColumnWidths(data: string[][]): number[] { const c: any = new (CsvEditorController as any)({} as any); diff --git a/src/test/sort-multiple-columns.test.ts b/src/test/sort-multiple-columns.test.ts new file mode 100644 index 0000000..ba0ade4 --- /dev/null +++ b/src/test/sort-multiple-columns.test.ts @@ -0,0 +1,116 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import Module from 'module'; + +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return {} as any; + } + return originalRequire.apply(this, arguments as any); +}; + +import { CsvEditorProvider } from '../CsvEditorProvider'; + +describe('Chained column sort', () => { + it('uses criteria in priority order', () => { + const rows = [ + ['group', 'score', 'name'], + ['B', '2', 'beta'], + ['A', '1', 'alpha'], + ['B', '4', 'delta'], + ['A', '3', 'gamma'] + ]; + + const sorted = CsvEditorProvider.__test.sortByColumns( + rows, + [ + { index: 0, ascending: true }, + { index: 1, ascending: false } + ], + true, + 0 + ); + + assert.deepStrictEqual(sorted, [ + ['group', 'score', 'name'], + ['A', '3', 'gamma'], + ['A', '1', 'alpha'], + ['B', '4', 'delta'], + ['B', '2', 'beta'] + ]); + }); + + it('continues to later criteria only for equal values', () => { + const rows = [ + ['A', 'same', 'z'], + ['A', 'same', 'a'], + ['A', 'other', 'm'] + ]; + + const sorted = CsvEditorProvider.__test.sortByColumns( + rows, + [ + { index: 0, ascending: true }, + { index: 1, ascending: true }, + { index: 2, ascending: true } + ], + false, + 0 + ); + + assert.deepStrictEqual(sorted, [ + ['A', 'other', 'm'], + ['A', 'same', 'a'], + ['A', 'same', 'z'] + ]); + }); + + it('removes a criterion without retaining its previous tie ordering', () => { + const baseline = [ + ['A', '1'], + ['A', '3'], + ['B', '2'], + ['B', '4'] + ]; + const withSecondary = CsvEditorProvider.__test.sortByColumns( + baseline, + [ + { index: 0, ascending: true }, + { index: 1, ascending: false } + ], + false, + 0 + ); + assert.deepStrictEqual(withSecondary, [ + ['A', '3'], + ['A', '1'], + ['B', '4'], + ['B', '2'] + ]); + + const withoutSecondary = CsvEditorProvider.__test.sortByColumns( + baseline, + [{ index: 0, ascending: true }], + false, + 0 + ); + assert.deepStrictEqual(withoutSecondary, baseline); + }); + + it('keeps empty values last for descending criteria', () => { + const rows = [ + ['A', '2'], + ['', '9'], + ['C', '1'], + ['B', '3'] + ]; + const sorted = CsvEditorProvider.__test.sortByColumns( + rows, + [{ index: 0, ascending: false }], + false, + 0 + ); + assert.deepStrictEqual(sorted.map(row => row[0]), ['C', 'B', 'A', '']); + }); +}); diff --git a/src/test/webview-reorder-interactions.test.ts b/src/test/webview-reorder-interactions.test.ts index 6ce7371..e2b6123 100644 --- a/src/test/webview-reorder-interactions.test.ts +++ b/src/test/webview-reorder-interactions.test.ts @@ -27,20 +27,38 @@ describe('Webview reorder and resize interactions', () => { it('cycles header clicks through no sort, ascending, and descending', () => { assert.ok(source.includes('scheduleHeaderSort(sourceIndex)')); - assert.ok(source.includes('requestColumnSort(col, true)')); - assert.ok(source.includes('requestColumnSort(col, false)')); - assert.ok(source.includes('clearColumnSortIndicator()')); - assert.ok(source.includes("header.setAttribute('aria-sort', st.sortAscending ? 'ascending' : 'descending')")); + assert.ok(source.includes('cycleColumnSortState(col)')); + assert.ok(source.includes('sorts.push({ index: col, ascending: true })')); + assert.ok(source.includes('existing.ascending = false')); + assert.ok(source.includes('sorts.splice(sorts.indexOf(existing), 1)')); + assert.ok(source.includes("header.setAttribute('aria-sort', sort.ascending ? 'ascending' : 'descending')")); }); - it('renders one copy-safe arrow and keeps double-click available for header editing', () => { + it('renders copy-safe arrows with their chained sort priorities', () => { assert.ok(source.includes("table.querySelectorAll('.sort-indicator').forEach(indicator => indicator.remove())")); - assert.ok(providerSource.includes('th[aria-sort="ascending"]::after { content: " ↑"')); - assert.ok(providerSource.includes('th[aria-sort="descending"]::after { content: " ↓"')); - assert.ok(source.includes("table.addEventListener('dblclick', e => {\n cancelPendingHeaderSort()")); + assert.ok(source.includes("header.setAttribute('data-sort-priority', String(priority + 1))")); + assert.ok(providerSource.includes('th[aria-sort="ascending"]::after { content: " ↑" attr(data-sort-priority)')); + assert.ok(providerSource.includes('th[aria-sort="descending"]::after { content: " ↓" attr(data-sort-priority)')); + assert.ok(source.includes("vscode.postMessage({ type: 'sortColumns', sorts })")); + }); + + it('reapplies every sort from a baseline and restores it when the chain is empty', () => { + assert.ok(providerSource.includes('this.sortBaselineText = this.document.getText()')); + assert.ok(providerSource.includes('const result = Papa.parse(this.sortBaselineText')); + assert.ok(providerSource.includes('edit.replace(this.document.uri, fullRange, originalText)')); + }); + + it('keeps double-click available for header editing', () => { + assert.ok(source.includes("table.addEventListener('dblclick', e => {\n cancelPendingHeaderSort(true)")); assert.ok(!source.includes("indicator.textContent = st.sortAscending")); }); + it('retains rapid clicks on different headers in the same chain', () => { + assert.ok(source.includes('pendingHeaderSortColumn === col')); + assert.ok(source.includes('pendingHeaderSortInitial = getColumnSorts()')); + assert.ok(source.includes("vscode.postMessage({ type: 'sortColumns', sorts })")); + }); + it('supports drag-resize for columns and rows', () => { assert.ok(source.includes('startResizeDrag')); assert.ok(source.includes('col-resize'));