Skip to content

Commit 28cb657

Browse files
committed
fix sorting
1 parent 843e0d7 commit 28cb657

File tree

6 files changed

+384
-33
lines changed

6 files changed

+384
-33
lines changed

src/CsvEditorProvider.ts

Lines changed: 148 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -160,31 +160,57 @@ class CsvEditorController {
160160
const data = result.data as string[][];
161161
const hadRows = data.length;
162162
const hadColsAtRow = (data[row] ? data[row].length : 0);
163+
164+
const { data: nextData, trimmed, createdRow, createdCol } = this.mutateDataForEdit(data, row, col, value);
165+
166+
const newCsvText = Papa.unparse(nextData, { delimiter: separator });
167+
168+
const fullRange = new vscode.Range(
169+
0, 0,
170+
this.document.lineCount,
171+
this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0
172+
);
173+
const edit = new vscode.WorkspaceEdit();
174+
edit.replace(this.document.uri, fullRange, newCsvText);
175+
await vscode.workspace.applyEdit(edit);
176+
177+
this.isUpdatingDocument = false;
178+
console.log(`CSV: Updated row ${row + 1}, column ${col + 1} to "${value}"`);
179+
this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value });
180+
181+
// Trigger a full re-render if structure may have changed (new row/col created)
182+
if (trimmed || createdRow || createdCol || row >= hadRows || col >= hadColsAtRow) {
183+
try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); }
184+
}
185+
}
186+
187+
// Apply an edit to a 2D data array, enforcing virtual row/cell invariants.
188+
// - Empty edits on non-existent virtual row/col are ignored
189+
// - Non-empty edits expand rows/cols as needed
190+
// - When editing the last row, trailing empty rows are trimmed
191+
private mutateDataForEdit(data: string[][], row: number, col: number, value: string): { data: string[][]; trimmed: boolean; createdRow: boolean; createdCol: boolean } {
192+
// Work on the same array instance (callers pass freshly parsed data)
193+
const hadRows = data.length;
194+
const hadColsAtRow = (data[row] ? data[row].length : 0);
163195
const wasEditingLastRow = row >= (data.length - 1);
164196

165197
const rowExists = row < data.length;
166-
const colExists = rowExists && col < data[row].length;
198+
const colExists = rowExists && col < (data[row]?.length ?? 0);
167199

168200
if (value === '') {
169201
if (!rowExists) {
170-
// Do not expand file with an empty virtual row
171-
this.isUpdatingDocument = false;
172-
return;
202+
return { data, trimmed: false, createdRow: false, createdCol: false };
173203
}
174204
if (!colExists) {
175-
// Do not expand row width with an empty virtual cell
176-
this.isUpdatingDocument = false;
177-
return;
205+
return { data, trimmed: false, createdRow: false, createdCol: false };
178206
}
179-
// Existing cell: clear value
180207
data[row][col] = '';
181208
} else {
182-
// Non-empty value: expand as needed and set
183209
while (data.length <= row) data.push([]);
184210
while (data[row].length <= col) data[row].push('');
185211
data[row][col] = value;
186212
}
187-
// If we edited the (previous) last row, trim trailing empty rows recursively
213+
188214
let trimmed = false;
189215
if (wasEditingLastRow) {
190216
const isRowEmpty = (arr: string[] | undefined) => {
@@ -200,25 +226,12 @@ class CsvEditorController {
200226
}
201227
}
202228

203-
const newCsvText = Papa.unparse(data, { delimiter: separator });
204-
205-
const fullRange = new vscode.Range(
206-
0, 0,
207-
this.document.lineCount,
208-
this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0
209-
);
210-
const edit = new vscode.WorkspaceEdit();
211-
edit.replace(this.document.uri, fullRange, newCsvText);
212-
await vscode.workspace.applyEdit(edit);
213-
214-
this.isUpdatingDocument = false;
215-
console.log(`CSV: Updated row ${row + 1}, column ${col + 1} to "${value}"`);
216-
this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value });
217-
218-
// Trigger a full re-render if structure may have changed (new row/col created)
219-
if (trimmed || row >= hadRows || col >= hadColsAtRow) {
220-
try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); }
221-
}
229+
return {
230+
data,
231+
trimmed,
232+
createdRow: value !== '' && row >= hadRows,
233+
createdCol: value !== '' && col >= hadColsAtRow
234+
};
222235
}
223236

224237
private async handleSave() {
@@ -347,7 +360,8 @@ class CsvEditorController {
347360

348361
const text = this.document.getText();
349362
const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator });
350-
const rows = result.data as string[][];
363+
// Exclude virtual/trailing empty rows from sort input
364+
const rows = this.trimTrailingEmptyRows(result.data as string[][]);
351365
const treatHeader = this.getEffectiveHeader(rows, this.getHiddenRows());
352366

353367
const offset = Math.min(Math.max(0, hidden), rows.length);
@@ -362,9 +376,26 @@ class CsvEditorController {
362376
}
363377

364378
const cmp = (a: string, b: string) => {
365-
const na = parseFloat(a), nb = parseFloat(b);
379+
const sa = (a ?? '').trim();
380+
const sb = (b ?? '').trim();
381+
const aEmpty = sa === '';
382+
const bEmpty = sb === '';
383+
if (aEmpty && bEmpty) return 0;
384+
if (aEmpty) return 1; // empty sorts last
385+
if (bEmpty) return -1;
386+
387+
// Dates take precedence over numeric compare (avoid parseFloat on ISO)
388+
const aIsDate = this.isDate(sa);
389+
const bIsDate = this.isDate(sb);
390+
if (aIsDate && bIsDate) {
391+
const da = Date.parse(sa);
392+
const db = Date.parse(sb);
393+
if (!isNaN(da) && !isNaN(db)) return da - db;
394+
}
395+
396+
const na = parseFloat(sa), nb = parseFloat(sb);
366397
if (!isNaN(na) && !isNaN(nb)) return na - nb;
367-
return a.localeCompare(b, undefined, { sensitivity: 'base' });
398+
return sa.localeCompare(sb, undefined, { sensitivity: 'base' });
368399
};
369400

370401
body.sort((r1, r2) => {
@@ -374,7 +405,19 @@ class CsvEditorController {
374405

375406
const prefix = rows.slice(0, offset);
376407
const combined = treatHeader ? [...prefix, header, ...body] : [...prefix, ...body];
377-
const newCsv = Papa.unparse(combined, { delimiter: separator });
408+
409+
// Sanitize before unparse: ensure undefined/null/NaN become empty strings
410+
const sanitized: string[][] = combined.map(r => r.map((v: any) => {
411+
if (v === undefined || v === null) return '';
412+
const t = typeof v;
413+
if (t === 'number') {
414+
return Number.isNaN(v) ? '' : String(v);
415+
}
416+
const s = String(v);
417+
return s.toLowerCase() === 'nan' ? '' : s;
418+
}));
419+
420+
const newCsv = Papa.unparse(sanitized, { delimiter: separator });
378421

379422
const fullRange = new vscode.Range(
380423
0, 0,
@@ -1038,10 +1081,75 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider {
10381081

10391082
// Test helpers to access internal utilities without VS Code runtime
10401083
public static __test = {
1084+
// Pure helper mirroring sort behavior; returns combined rows after sort.
1085+
sortByColumn(rows: string[][], index: number, ascending: boolean, treatHeader: boolean, hiddenRows: number): string[][] {
1086+
// Trim trailing empty rows like runtime before sorting
1087+
const isEmpty = (r: string[] | undefined) => {
1088+
if (!r || r.length === 0) return true;
1089+
for (let i = 0; i < r.length; i++) { if ((r[i] ?? '') !== '') return false; }
1090+
return true;
1091+
};
1092+
let end = rows.length;
1093+
while (end > 0 && isEmpty(rows[end - 1])) { end--; }
1094+
const trimmed = rows.slice(0, end);
1095+
1096+
const offset = Math.min(Math.max(0, hiddenRows), trimmed.length);
1097+
let header: string[] = [];
1098+
let body: string[][] = [];
1099+
if (treatHeader && offset < trimmed.length) {
1100+
header = trimmed[offset];
1101+
body = trimmed.slice(offset + 1);
1102+
} else {
1103+
body = trimmed.slice(offset);
1104+
}
1105+
const isDateStr = (v: string) => {
1106+
const s = (v ?? '').trim();
1107+
if (!s) return false;
1108+
const isoDate = /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
1109+
const isoSlash = /^\d{4}\/\d{2}\/\d{2}$/;
1110+
return isoDate.test(s) || isoSlash.test(s);
1111+
};
1112+
const cmp = (a: string, b: string) => {
1113+
const sa = (a ?? '').trim();
1114+
const sb = (b ?? '').trim();
1115+
const aEmpty = sa === '';
1116+
const bEmpty = sb === '';
1117+
if (aEmpty && bEmpty) return 0;
1118+
if (aEmpty) return 1; // empty sorts last
1119+
if (bEmpty) return -1;
1120+
if (isDateStr(sa) && isDateStr(sb)) {
1121+
const da = Date.parse(sa);
1122+
const db = Date.parse(sb);
1123+
if (!isNaN(da) && !isNaN(db)) return da - db;
1124+
}
1125+
const na = parseFloat(sa), nb = parseFloat(sb);
1126+
if (!isNaN(na) && !isNaN(nb)) return na - nb;
1127+
return sa.localeCompare(sb, undefined, { sensitivity: 'base' });
1128+
};
1129+
body.sort((r1, r2) => {
1130+
const diff = cmp(r1[index] ?? '', r2[index] ?? '');
1131+
return ascending ? diff : -diff;
1132+
});
1133+
const prefix = trimmed.slice(0, offset);
1134+
1135+
// Apply same sanitation used before unparse in runtime path
1136+
const combined = (treatHeader ? [...prefix, header, ...body] : [...prefix, ...body]).map(r => r.map((v: any) => {
1137+
if (v === undefined || v === null) return '';
1138+
const t = typeof v;
1139+
if (t === 'number') return Number.isNaN(v) ? '' : String(v);
1140+
const s = String(v);
1141+
return s.toLowerCase() === 'nan' ? '' : s;
1142+
}));
1143+
return combined;
1144+
},
10411145
computeColumnWidths(data: string[][]): number[] {
10421146
const c: any = new (CsvEditorController as any)({} as any);
10431147
return c.computeColumnWidths(data);
10441148
},
1149+
mutateDataForEdit(data: string[][], row: number, col: number, value: string): { data: string[][]; trimmed: boolean; createdRow: boolean; createdCol: boolean } {
1150+
const c: any = new (CsvEditorController as any)({} as any);
1151+
return c.mutateDataForEdit(data, row, col, value);
1152+
},
10451153
isDate(v: string): boolean {
10461154
const c: any = new (CsvEditorController as any)({} as any);
10471155
return c.isDate(v);
@@ -1104,6 +1212,13 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider {
11041212
} catch {
11051213
return { chunkCount: 0, hasTable: false };
11061214
}
1215+
},
1216+
generateTableAndChunksRaw(data: string[][], treatHeader: boolean, addSerialIndex: boolean, hiddenRows: number): { tableHtml: string; chunks: string[] } {
1217+
const c: any = new (CsvEditorController as any)({} as any);
1218+
const result = c.generateTableAndChunks(data, treatHeader, addSerialIndex, hiddenRows);
1219+
let chunks: string[] = [];
1220+
try { chunks = JSON.parse(result.chunksJson); } catch {}
1221+
return { tableHtml: result.tableHtml, chunks };
11071222
}
11081223
};
11091224
}

src/test/edit-mutate.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import assert from 'assert';
2+
import { describe, it } from 'node:test';
3+
import Module from 'module';
4+
5+
// Stub 'vscode' before importing provider
6+
const originalRequire = Module.prototype.require;
7+
Module.prototype.require = function (id: string) {
8+
if (id === 'vscode') {
9+
return {
10+
window: { activeColorTheme: { kind: 1 } },
11+
ColorThemeKind: { Dark: 1 }
12+
} as any;
13+
}
14+
return originalRequire.apply(this, arguments as any);
15+
};
16+
17+
import { CsvEditorProvider } from '../CsvEditorProvider';
18+
19+
describe('Edit mutate invariants', () => {
20+
it('does not promote virtual row on empty edit', () => {
21+
const res = CsvEditorProvider.__test.mutateDataForEdit([], 0, 0, '');
22+
assert.deepStrictEqual(res.data, []);
23+
assert.strictEqual(res.createdRow, false);
24+
});
25+
26+
it('does not promote virtual cell on empty edit into new column', () => {
27+
const init = [['a']];
28+
const res = CsvEditorProvider.__test.mutateDataForEdit(init.map(r => [...r]), 0, 2, '');
29+
assert.deepStrictEqual(res.data, [['a']]);
30+
assert.strictEqual(res.createdCol, false);
31+
});
32+
33+
it('non-empty edit expands rows and columns as needed', () => {
34+
const a = CsvEditorProvider.__test.mutateDataForEdit([], 0, 0, 'x');
35+
assert.deepStrictEqual(a.data, [['x']]);
36+
assert.strictEqual(a.createdRow, true);
37+
assert.strictEqual(a.createdCol, true);
38+
39+
const b = CsvEditorProvider.__test.mutateDataForEdit([['a']], 0, 2, 'v');
40+
assert.deepStrictEqual(b.data, [['a', '', 'v']]);
41+
assert.strictEqual(b.createdCol, true);
42+
});
43+
44+
it('trims trailing empty rows when editing last row', () => {
45+
const init = [['a'], ['']];
46+
const res = CsvEditorProvider.__test.mutateDataForEdit(init.map(r => [...r]), 1, 0, '');
47+
assert.deepStrictEqual(res.data, [['a']]);
48+
assert.strictEqual(res.trimmed, true);
49+
});
50+
});
51+

src/test/sort-dates.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import assert from 'assert';
2+
import { describe, it } from 'node:test';
3+
import Module from 'module';
4+
5+
// Stub 'vscode' prior to loading provider (theme checks)
6+
const originalRequire = Module.prototype.require;
7+
Module.prototype.require = function (id: string) {
8+
if (id === 'vscode') {
9+
return {
10+
window: { activeColorTheme: { kind: 1 } },
11+
ColorThemeKind: { Dark: 1 }
12+
} as any;
13+
}
14+
return originalRequire.apply(this, arguments as any);
15+
};
16+
17+
import { CsvEditorProvider } from '../CsvEditorProvider';
18+
19+
describe('Date column sort', () => {
20+
it('sorts ISO yyyy-mm-dd ascending and descending', () => {
21+
const input = [
22+
['2017-02-18'],
23+
['2017-12-04'],
24+
['2017-02-10'],
25+
['2017-04-16'],
26+
['2017-06-22'],
27+
['2017-04-08'],
28+
['2017-06-14'],
29+
['2017-08-20']
30+
];
31+
32+
const asc = CsvEditorProvider.__test.sortByColumn(input, 0, true, false, 0).map(r => r[0]);
33+
assert.deepStrictEqual(asc, [
34+
'2017-02-10',
35+
'2017-02-18',
36+
'2017-04-08',
37+
'2017-04-16',
38+
'2017-06-14',
39+
'2017-06-22',
40+
'2017-08-20',
41+
'2017-12-04'
42+
]);
43+
44+
const desc = CsvEditorProvider.__test.sortByColumn(input, 0, false, false, 0).map(r => r[0]);
45+
assert.deepStrictEqual(desc, [
46+
'2017-12-04',
47+
'2017-08-20',
48+
'2017-06-22',
49+
'2017-06-14',
50+
'2017-04-16',
51+
'2017-04-08',
52+
'2017-02-18',
53+
'2017-02-10'
54+
]);
55+
});
56+
57+
it('treats empty dates as last in ascending', () => {
58+
const input = [ ['2017-01-01'], [''], ['2017-01-03'] ];
59+
const asc = CsvEditorProvider.__test.sortByColumn(input, 0, true, false, 0).map(r => r[0]);
60+
assert.deepStrictEqual(asc, ['2017-01-01', '2017-01-03', '']);
61+
});
62+
});
63+

0 commit comments

Comments
 (0)