@@ -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}
0 commit comments