dbn-cli 0.3.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/adapter/adapter.test.ts +207 -0
- package/src/adapter/base.ts +14 -1
- package/src/adapter/sqlite.ts +71 -1
- package/src/index.ts +59 -2
- package/src/repro_bug.test.ts +101 -0
- package/src/types.ts +54 -1
- package/src/ui/grit/README.md +58 -0
- package/src/ui/grit/index.test.ts +67 -0
- package/src/ui/grit/index.ts +101 -0
- package/src/ui/grit/types.ts +16 -0
- package/src/ui/grit/utils.ts +35 -0
- package/src/ui/navigator.test.ts +434 -0
- package/src/ui/navigator.ts +385 -27
- package/src/ui/navigator_sync.test.ts +95 -0
- package/src/ui/renderer.ts +247 -409
- package/src/ui/screen.ts +6 -6
- package/src/ui/theme.test.ts +30 -0
- package/src/ui/theme.ts +20 -46
- package/src/utils/format.test.ts +209 -0
- package/src/utils/format.ts +94 -21
package/src/ui/navigator.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DatabaseAdapter } from '../adapter/base.ts';
|
|
2
|
-
import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState } from '../types.ts';
|
|
2
|
+
import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState, HealthViewState, ColumnSchema } from '../types.ts';
|
|
3
|
+
import { getVisibleWidth, formatValue } from '../utils/format.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Navigation state manager
|
|
@@ -45,11 +46,15 @@ export class Navigator {
|
|
|
45
46
|
moveUp(): void {
|
|
46
47
|
const state = this.currentState;
|
|
47
48
|
if (!state) return;
|
|
48
|
-
|
|
49
|
+
|
|
49
50
|
if (state.type === 'tables') {
|
|
50
51
|
if (state.cursor > 0) {
|
|
51
52
|
state.cursor--;
|
|
52
53
|
}
|
|
54
|
+
} else if (state.type === 'row-detail') {
|
|
55
|
+
if (state.scrollOffset > 0) {
|
|
56
|
+
state.scrollOffset--;
|
|
57
|
+
}
|
|
53
58
|
} else if (state.type === 'table-detail') {
|
|
54
59
|
if (state.dataCursor > 0) {
|
|
55
60
|
state.dataCursor--;
|
|
@@ -71,11 +76,16 @@ export class Navigator {
|
|
|
71
76
|
moveDown(): void {
|
|
72
77
|
const state = this.currentState;
|
|
73
78
|
if (!state) return;
|
|
74
|
-
|
|
79
|
+
|
|
75
80
|
if (state.type === 'tables') {
|
|
76
81
|
if (state.cursor < state.tables.length - 1) {
|
|
77
82
|
state.cursor++;
|
|
78
83
|
}
|
|
84
|
+
} else if (state.type === 'row-detail') {
|
|
85
|
+
const maxScroll = Math.max(0, state.totalLines - state.visibleHeight);
|
|
86
|
+
if (state.scrollOffset < maxScroll) {
|
|
87
|
+
state.scrollOffset++;
|
|
88
|
+
}
|
|
79
89
|
} else if (state.type === 'table-detail') {
|
|
80
90
|
const maxCursor = Math.min(state.data.length - 1, state.visibleRows - 1);
|
|
81
91
|
if (state.dataCursor < maxCursor) {
|
|
@@ -98,9 +108,11 @@ export class Navigator {
|
|
|
98
108
|
jumpToTop(): void {
|
|
99
109
|
const state = this.currentState;
|
|
100
110
|
if (!state) return;
|
|
101
|
-
|
|
111
|
+
|
|
102
112
|
if (state.type === 'tables') {
|
|
103
113
|
state.cursor = 0;
|
|
114
|
+
} else if (state.type === 'row-detail') {
|
|
115
|
+
state.scrollOffset = 0;
|
|
104
116
|
} else if (state.type === 'table-detail') {
|
|
105
117
|
state.dataOffset = 0;
|
|
106
118
|
state.dataCursor = 0;
|
|
@@ -116,9 +128,11 @@ export class Navigator {
|
|
|
116
128
|
jumpToBottom(): void {
|
|
117
129
|
const state = this.currentState;
|
|
118
130
|
if (!state) return;
|
|
119
|
-
|
|
131
|
+
|
|
120
132
|
if (state.type === 'tables') {
|
|
121
133
|
state.cursor = state.tables.length - 1;
|
|
134
|
+
} else if (state.type === 'row-detail') {
|
|
135
|
+
state.scrollOffset = Math.max(0, state.totalLines - state.visibleHeight);
|
|
122
136
|
} else if (state.type === 'table-detail') {
|
|
123
137
|
const lastPageOffset = Math.max(0, state.totalRows - state.visibleRows);
|
|
124
138
|
state.dataOffset = lastPageOffset;
|
|
@@ -154,7 +168,7 @@ export class Navigator {
|
|
|
154
168
|
cursor: 0,
|
|
155
169
|
scrollOffset: 0
|
|
156
170
|
};
|
|
157
|
-
|
|
171
|
+
|
|
158
172
|
this.states.push(newState);
|
|
159
173
|
this.currentState = newState;
|
|
160
174
|
}
|
|
@@ -166,16 +180,17 @@ export class Navigator {
|
|
|
166
180
|
enter(): void {
|
|
167
181
|
const state = this.currentState;
|
|
168
182
|
if (!state) return;
|
|
169
|
-
|
|
183
|
+
|
|
170
184
|
if (state.type === 'tables') {
|
|
171
185
|
const selectedTable = state.tables[state.cursor];
|
|
172
186
|
if (!selectedTable) return;
|
|
173
|
-
|
|
187
|
+
|
|
174
188
|
// Load table details
|
|
175
189
|
const schema = this.adapter.getTableSchema(selectedTable.name);
|
|
176
190
|
const totalRows = selectedTable.row_count;
|
|
177
|
-
const data = this.adapter.getTableData(selectedTable.name, { limit:
|
|
178
|
-
|
|
191
|
+
const data = this.adapter.getTableData(selectedTable.name, { limit: 500, offset: 0 });
|
|
192
|
+
const columnWeights = this.calculateColumnWeights(selectedTable.name, schema, totalRows);
|
|
193
|
+
|
|
179
194
|
const newState: TableDetailViewState = {
|
|
180
195
|
type: 'table-detail',
|
|
181
196
|
tableName: selectedTable.name,
|
|
@@ -184,32 +199,114 @@ export class Navigator {
|
|
|
184
199
|
totalRows: totalRows,
|
|
185
200
|
dataOffset: 0,
|
|
186
201
|
dataCursor: 0,
|
|
202
|
+
bufferOffset: 0,
|
|
187
203
|
visibleRows: 20, // Will be updated by renderer
|
|
188
|
-
showSchema: false // Schema display toggle
|
|
204
|
+
showSchema: false, // Schema display toggle
|
|
205
|
+
columnWeights: columnWeights
|
|
189
206
|
};
|
|
190
|
-
|
|
207
|
+
|
|
191
208
|
this.states.push(newState);
|
|
192
209
|
this.currentState = newState;
|
|
193
210
|
} else if (state.type === 'table-detail') {
|
|
194
211
|
// Enter row detail view
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (!selectedRow) return;
|
|
198
|
-
|
|
212
|
+
const selectedRow = this.getSelectedRow(state);
|
|
213
|
+
if (selectedRow) {
|
|
199
214
|
const newState: RowDetailViewState = {
|
|
200
215
|
type: 'row-detail',
|
|
201
216
|
tableName: state.tableName,
|
|
202
217
|
row: selectedRow,
|
|
203
218
|
rowIndex: state.dataOffset + state.dataCursor,
|
|
204
|
-
|
|
219
|
+
totalRows: state.totalRows,
|
|
220
|
+
schema: state.schema,
|
|
221
|
+
scrollOffset: 0,
|
|
222
|
+
totalLines: 0,
|
|
223
|
+
visibleHeight: 0
|
|
205
224
|
};
|
|
206
|
-
|
|
225
|
+
|
|
207
226
|
this.states.push(newState);
|
|
208
227
|
this.currentState = newState;
|
|
209
228
|
}
|
|
210
229
|
}
|
|
211
230
|
}
|
|
212
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Switch to next record in row-detail view
|
|
234
|
+
*/
|
|
235
|
+
nextRecord(): void {
|
|
236
|
+
const state = this.currentState;
|
|
237
|
+
if (state && state.type === 'row-detail') {
|
|
238
|
+
if (state.rowIndex < state.totalRows - 1) {
|
|
239
|
+
state.rowIndex++;
|
|
240
|
+
// Fetch new row data
|
|
241
|
+
const data = this.adapter.getTableData(state.tableName, { limit: 1, offset: state.rowIndex });
|
|
242
|
+
if (data.length > 0) {
|
|
243
|
+
state.row = data[0];
|
|
244
|
+
state.scrollOffset = 0;
|
|
245
|
+
this.syncParentState(state.rowIndex);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Switch to previous record in row-detail view
|
|
253
|
+
*/
|
|
254
|
+
prevRecord(): void {
|
|
255
|
+
const state = this.currentState;
|
|
256
|
+
if (state && state.type === 'row-detail') {
|
|
257
|
+
if (state.rowIndex > 0) {
|
|
258
|
+
state.rowIndex--;
|
|
259
|
+
// Fetch new row data
|
|
260
|
+
const data = this.adapter.getTableData(state.tableName, { limit: 1, offset: state.rowIndex });
|
|
261
|
+
if (data.length > 0) {
|
|
262
|
+
state.row = data[0];
|
|
263
|
+
state.scrollOffset = 0;
|
|
264
|
+
this.syncParentState(state.rowIndex);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Helper to sync parent state with current row index
|
|
272
|
+
*/
|
|
273
|
+
private syncParentState(rowIndex: number): void {
|
|
274
|
+
const parent = this.states[this.states.length - 2];
|
|
275
|
+
if (parent && parent.type === 'table-detail') {
|
|
276
|
+
const visibleRows = parent.visibleRows || 20;
|
|
277
|
+
|
|
278
|
+
// Try to keep the row within the visible window if possible
|
|
279
|
+
const currentPos = parent.dataOffset + parent.dataCursor;
|
|
280
|
+
if (rowIndex === currentPos) return;
|
|
281
|
+
|
|
282
|
+
let newOffset = parent.dataOffset;
|
|
283
|
+
let newCursor = rowIndex - newOffset;
|
|
284
|
+
|
|
285
|
+
if (newCursor < 0) {
|
|
286
|
+
newOffset = rowIndex;
|
|
287
|
+
newCursor = 0;
|
|
288
|
+
} else if (newCursor >= visibleRows) {
|
|
289
|
+
newOffset = rowIndex - (visibleRows - 1);
|
|
290
|
+
newCursor = visibleRows - 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
parent.dataOffset = newOffset;
|
|
294
|
+
parent.dataCursor = newCursor;
|
|
295
|
+
|
|
296
|
+
// Ensure data is reloaded in parent buffer if needed
|
|
297
|
+
this.reloadState(parent);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/*
|
|
302
|
+
* Helper to get the currently selected row from table data buffer
|
|
303
|
+
*/
|
|
304
|
+
private getSelectedRow(state: TableDetailViewState): Record<string, any> | undefined {
|
|
305
|
+
if (state.data.length === 0) return undefined;
|
|
306
|
+
const bufferIndex = state.dataOffset - state.bufferOffset + state.dataCursor;
|
|
307
|
+
return state.data[bufferIndex];
|
|
308
|
+
}
|
|
309
|
+
|
|
213
310
|
/**
|
|
214
311
|
* Go back to previous view
|
|
215
312
|
*/
|
|
@@ -221,21 +318,282 @@ export class Navigator {
|
|
|
221
318
|
}
|
|
222
319
|
|
|
223
320
|
/**
|
|
224
|
-
*
|
|
321
|
+
* View core health overview
|
|
322
|
+
*/
|
|
323
|
+
viewHealth(): void {
|
|
324
|
+
const state = this.currentState;
|
|
325
|
+
if (!state || state.type !== 'tables') return;
|
|
326
|
+
|
|
327
|
+
const info = this.adapter.getHealthInfo();
|
|
328
|
+
const newState: HealthViewState = {
|
|
329
|
+
type: 'health',
|
|
330
|
+
info
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
this.states.push(newState);
|
|
334
|
+
this.currentState = newState;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Request delete for the currently selected row
|
|
339
|
+
*/
|
|
340
|
+
requestDelete(): void {
|
|
341
|
+
const state = this.currentState;
|
|
342
|
+
if (!state) return;
|
|
343
|
+
|
|
344
|
+
if (state.type === 'table-detail') {
|
|
345
|
+
state.deleteConfirm = undefined;
|
|
346
|
+
const selectedRow = this.getSelectedRow(state);
|
|
347
|
+
if (!selectedRow) {
|
|
348
|
+
state.notice = 'No row selected';
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const result = this.getPrimaryKeyValues(state.schema, selectedRow);
|
|
353
|
+
if ('error' in result) {
|
|
354
|
+
state.notice = result.error;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
state.deleteConfirm = {
|
|
359
|
+
tableName: state.tableName,
|
|
360
|
+
rowIndex: state.dataOffset + state.dataCursor,
|
|
361
|
+
keyValues: result.keyValues,
|
|
362
|
+
step: 1
|
|
363
|
+
};
|
|
364
|
+
state.notice = `Delete row ${state.dataOffset + state.dataCursor + 1}? Press y`;
|
|
365
|
+
} else if (state.type === 'row-detail') {
|
|
366
|
+
state.deleteConfirm = undefined;
|
|
367
|
+
const result = this.getPrimaryKeyValues(state.schema, state.row);
|
|
368
|
+
if ('error' in result) {
|
|
369
|
+
state.notice = result.error;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
state.deleteConfirm = {
|
|
374
|
+
tableName: state.tableName,
|
|
375
|
+
rowIndex: state.rowIndex,
|
|
376
|
+
keyValues: result.keyValues,
|
|
377
|
+
step: 1
|
|
378
|
+
};
|
|
379
|
+
state.notice = `Delete row ${state.rowIndex + 1}? Press y`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Confirm delete (multi-step)
|
|
385
|
+
*/
|
|
386
|
+
confirmDelete(): void {
|
|
387
|
+
const state = this.currentState;
|
|
388
|
+
if (!state) return;
|
|
389
|
+
|
|
390
|
+
if (state.type === 'table-detail' && state.deleteConfirm) {
|
|
391
|
+
const confirm = state.deleteConfirm;
|
|
392
|
+
|
|
393
|
+
if (confirm.step === 1) {
|
|
394
|
+
state.deleteConfirm = { ...confirm, step: 2 };
|
|
395
|
+
state.notice = `Delete row ${confirm.rowIndex + 1}? Press y again`;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
this.adapter.deleteRow(confirm.tableName, confirm.keyValues);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
state.notice = `Delete failed: ${(error as Error).message}`;
|
|
403
|
+
state.deleteConfirm = undefined;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
state.deleteConfirm = undefined;
|
|
408
|
+
state.notice = `Row ${confirm.rowIndex + 1} deleted`;
|
|
409
|
+
state.totalRows = this.adapter.getRowCount(state.tableName);
|
|
410
|
+
|
|
411
|
+
const visibleRows = state.visibleRows || 20;
|
|
412
|
+
const maxOffset = Math.max(0, state.totalRows - visibleRows);
|
|
413
|
+
state.dataOffset = Math.min(state.dataOffset, maxOffset);
|
|
414
|
+
|
|
415
|
+
this.reload(true);
|
|
416
|
+
if (state.dataCursor >= state.data.length) {
|
|
417
|
+
state.dataCursor = Math.max(0, state.data.length - 1);
|
|
418
|
+
}
|
|
419
|
+
} else if (state.type === 'row-detail' && state.deleteConfirm) {
|
|
420
|
+
const confirm = state.deleteConfirm;
|
|
421
|
+
|
|
422
|
+
if (confirm.step === 1) {
|
|
423
|
+
state.deleteConfirm = { ...confirm, step: 2 };
|
|
424
|
+
state.notice = `Delete row ${confirm.rowIndex + 1}? Press y again`;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
this.adapter.deleteRow(confirm.tableName, confirm.keyValues);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
state.notice = `Delete failed: ${(error as Error).message}`;
|
|
432
|
+
state.deleteConfirm = undefined;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const parent = this.states[this.states.length - 2];
|
|
437
|
+
if (parent && parent.type === 'table-detail') {
|
|
438
|
+
parent.notice = `Row ${confirm.rowIndex + 1} deleted`;
|
|
439
|
+
parent.totalRows = this.adapter.getRowCount(parent.tableName);
|
|
440
|
+
|
|
441
|
+
// Sync parent cursor to the deleted row's position
|
|
442
|
+
this.syncParentState(confirm.rowIndex);
|
|
443
|
+
|
|
444
|
+
const maxOffset = Math.max(0, parent.totalRows - parent.visibleRows);
|
|
445
|
+
parent.dataOffset = Math.min(parent.dataOffset, maxOffset);
|
|
446
|
+
|
|
447
|
+
parent.deleteConfirm = undefined;
|
|
448
|
+
this.states.pop();
|
|
449
|
+
this.currentState = parent;
|
|
450
|
+
this.reload(true);
|
|
451
|
+
|
|
452
|
+
if (parent.dataCursor >= parent.data.length) {
|
|
453
|
+
parent.dataCursor = Math.max(0, parent.data.length - 1);
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
state.notice = `Row ${confirm.rowIndex + 1} deleted`;
|
|
457
|
+
state.deleteConfirm = undefined;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Cancel delete confirmation
|
|
225
464
|
*/
|
|
226
|
-
|
|
465
|
+
cancelDelete(): void {
|
|
227
466
|
const state = this.currentState;
|
|
228
|
-
|
|
467
|
+
if (!state) return;
|
|
468
|
+
|
|
469
|
+
if (state.type === 'table-detail' && state.deleteConfirm) {
|
|
470
|
+
state.deleteConfirm = undefined;
|
|
471
|
+
state.notice = 'Delete cancelled';
|
|
472
|
+
} else if (state.type === 'row-detail' && state.deleteConfirm) {
|
|
473
|
+
state.deleteConfirm = undefined;
|
|
474
|
+
state.notice = 'Delete cancelled';
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Check if the current view is awaiting delete confirmation
|
|
480
|
+
*/
|
|
481
|
+
hasPendingDelete(): boolean {
|
|
482
|
+
const state = this.currentState;
|
|
483
|
+
if (!state) return false;
|
|
484
|
+
|
|
485
|
+
if (state.type === 'table-detail' || state.type === 'row-detail') {
|
|
486
|
+
return Boolean(state.deleteConfirm);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Reload current view data
|
|
494
|
+
*/
|
|
495
|
+
reload(force: boolean = false): void {
|
|
496
|
+
if (this.currentState) {
|
|
497
|
+
this.reloadState(this.currentState, force);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Reload specific view state data
|
|
503
|
+
*/
|
|
504
|
+
private reloadState(state: ViewState, force: boolean = false): void {
|
|
229
505
|
if (state && state.type === 'table-detail') {
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
506
|
+
const bufferSize = 500;
|
|
507
|
+
const currentPos = state.dataOffset + state.dataCursor;
|
|
508
|
+
|
|
509
|
+
// Check if we need to reload data
|
|
510
|
+
const needsReload = force ||
|
|
511
|
+
state.data.length === 0 ||
|
|
512
|
+
currentPos < state.bufferOffset ||
|
|
513
|
+
currentPos >= state.bufferOffset + state.data.length;
|
|
514
|
+
|
|
515
|
+
if (needsReload) {
|
|
516
|
+
// Calculate new buffer offset to keep current view in middle if possible
|
|
517
|
+
const halfBuffer = Math.floor(bufferSize / 2);
|
|
518
|
+
const newBufferOffset = Math.max(0, Math.min(
|
|
519
|
+
currentPos - halfBuffer,
|
|
520
|
+
state.totalRows - bufferSize
|
|
521
|
+
));
|
|
522
|
+
|
|
523
|
+
state.data = this.adapter.getTableData(state.tableName, {
|
|
524
|
+
limit: bufferSize,
|
|
525
|
+
offset: newBufferOffset
|
|
526
|
+
});
|
|
527
|
+
state.bufferOffset = newBufferOffset;
|
|
528
|
+
}
|
|
236
529
|
}
|
|
237
530
|
}
|
|
238
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Calculate weights for each column based on type, name and sample data
|
|
534
|
+
*/
|
|
535
|
+
private calculateColumnWeights(tableName: string, schema: ColumnSchema[], totalRows: number): number[] {
|
|
536
|
+
// Fetch latest 10 rows for sampling
|
|
537
|
+
const sampleSize = 10;
|
|
538
|
+
const sampleData = this.adapter.getTableData(tableName, {
|
|
539
|
+
limit: sampleSize,
|
|
540
|
+
offset: Math.max(0, totalRows - sampleSize)
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return schema.map(col => {
|
|
544
|
+
// 1. Base weight by type
|
|
545
|
+
let typeWeight = 15;
|
|
546
|
+
const type = col.type.toUpperCase();
|
|
547
|
+
if (type.includes('CHAR') || type.includes('TEXT') || type.includes('CLOB') || type.includes('BLOB')) {
|
|
548
|
+
typeWeight = 20;
|
|
549
|
+
} else if (type.includes('INT')) {
|
|
550
|
+
typeWeight = 10;
|
|
551
|
+
} else if (type.includes('TIME') || type.includes('DATE')) {
|
|
552
|
+
typeWeight = 18;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 2. Average width from sample data (with cap per row)
|
|
556
|
+
let avgDataWidth = 0;
|
|
557
|
+
if (sampleData.length > 0) {
|
|
558
|
+
const totalWidth = sampleData.reduce((sum, row) => {
|
|
559
|
+
// Cap individual row width to 50 to avoid Base64/long text outliers
|
|
560
|
+
// from skewing the weight calculation for everyone else
|
|
561
|
+
const valWidth = getVisibleWidth(formatValue(row[col.name]));
|
|
562
|
+
return sum + Math.min(valWidth, 50);
|
|
563
|
+
}, 0);
|
|
564
|
+
avgDataWidth = totalWidth / sampleData.length;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 3. Name width (capped at 20)
|
|
568
|
+
const nameWidth = Math.min(getVisibleWidth(col.name), 20);
|
|
569
|
+
|
|
570
|
+
// Final weight is max of all factors
|
|
571
|
+
return Math.max(nameWidth, typeWeight, Math.ceil(avgDataWidth));
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private getPrimaryKeyValues(
|
|
576
|
+
schema: TableDetailViewState['schema'],
|
|
577
|
+
row: Record<string, any>
|
|
578
|
+
): { keyValues: Record<string, any> } | { error: string } {
|
|
579
|
+
const pkColumns = schema
|
|
580
|
+
.filter(col => col.pk)
|
|
581
|
+
.sort((a, b) => a.pk - b.pk);
|
|
582
|
+
if (pkColumns.length === 0) {
|
|
583
|
+
return { error: 'Cannot delete: table has no primary key' };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const keyValues: Record<string, any> = {};
|
|
587
|
+
for (const col of pkColumns) {
|
|
588
|
+
if (!(col.name in row)) {
|
|
589
|
+
return { error: `Cannot delete: missing primary key value for ${col.name}` };
|
|
590
|
+
}
|
|
591
|
+
keyValues[col.name] = row[col.name];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return { keyValues };
|
|
595
|
+
}
|
|
596
|
+
|
|
239
597
|
/**
|
|
240
598
|
* Get breadcrumb path
|
|
241
599
|
* @returns Breadcrumb path string
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { unlinkSync, existsSync } from 'node:fs';
|
|
5
|
+
import { SQLiteAdapter } from '../adapter/sqlite.ts';
|
|
6
|
+
import { Navigator } from './navigator.ts';
|
|
7
|
+
|
|
8
|
+
const SYNC_DB = './sync-test.db';
|
|
9
|
+
|
|
10
|
+
describe('Navigator Synchronization', () => {
|
|
11
|
+
let adapter: SQLiteAdapter;
|
|
12
|
+
let navigator: Navigator;
|
|
13
|
+
|
|
14
|
+
before(() => {
|
|
15
|
+
const db = new DatabaseSync(SYNC_DB);
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE TABLE items (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
name TEXT
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
const insert = db.prepare('INSERT INTO items (name) VALUES (?)');
|
|
24
|
+
for (let i = 1; i <= 50; i++) {
|
|
25
|
+
insert.run(`Item ${i}`);
|
|
26
|
+
}
|
|
27
|
+
db.close();
|
|
28
|
+
|
|
29
|
+
adapter = new SQLiteAdapter();
|
|
30
|
+
adapter.connect(SYNC_DB);
|
|
31
|
+
navigator = new Navigator(adapter);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
after(() => {
|
|
35
|
+
adapter.close();
|
|
36
|
+
if (existsSync(SYNC_DB)) {
|
|
37
|
+
unlinkSync(SYNC_DB);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should sync table-detail cursor when navigating in row-detail', () => {
|
|
42
|
+
navigator.init();
|
|
43
|
+
navigator.enter(); // Enter 'items' table
|
|
44
|
+
|
|
45
|
+
// Start at row 0
|
|
46
|
+
let state = navigator.getState() as any;
|
|
47
|
+
assert.strictEqual(state.type, 'table-detail');
|
|
48
|
+
assert.strictEqual(state.dataCursor, 0);
|
|
49
|
+
assert.strictEqual(state.dataOffset, 0);
|
|
50
|
+
|
|
51
|
+
navigator.enter(); // Enter row-detail for Row 0
|
|
52
|
+
let rowState = navigator.getState() as any;
|
|
53
|
+
assert.strictEqual(rowState.type, 'row-detail');
|
|
54
|
+
assert.strictEqual(rowState.rowIndex, 0);
|
|
55
|
+
|
|
56
|
+
// Move to next record (Row 1)
|
|
57
|
+
navigator.nextRecord();
|
|
58
|
+
rowState = navigator.getState() as any;
|
|
59
|
+
assert.strictEqual(rowState.rowIndex, 1);
|
|
60
|
+
assert.strictEqual(rowState.row.name, 'Item 2');
|
|
61
|
+
|
|
62
|
+
// Go back to table-detail
|
|
63
|
+
navigator.back();
|
|
64
|
+
state = navigator.getState() as any;
|
|
65
|
+
assert.strictEqual(state.type, 'table-detail');
|
|
66
|
+
|
|
67
|
+
// VERIFY SYNC: The table detail should now be at row 1
|
|
68
|
+
// (This is expected to FAIL before the fix)
|
|
69
|
+
assert.strictEqual(state.dataCursor + state.dataOffset, 1, 'Table detail cursor should be synced to Row 1');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should sync table-detail cursor when deleting in row-detail', () => {
|
|
73
|
+
navigator.init();
|
|
74
|
+
navigator.enter(); // Enter 'items' table
|
|
75
|
+
|
|
76
|
+
// Move to row 5
|
|
77
|
+
for (let i = 0; i < 5; i++) navigator.moveDown();
|
|
78
|
+
|
|
79
|
+
navigator.enter(); // Enter row-detail for Row 5 (Item 6)
|
|
80
|
+
navigator.requestDelete();
|
|
81
|
+
navigator.confirmDelete(); // 1st confirm
|
|
82
|
+
navigator.confirmDelete(); // 2nd confirm (actually performs delete and goes back)
|
|
83
|
+
|
|
84
|
+
const state = navigator.getState() as any;
|
|
85
|
+
assert.strictEqual(state.type, 'table-detail');
|
|
86
|
+
|
|
87
|
+
// After deleting row 5, the cursor should still be at 5 (pointing to what was Row 6)
|
|
88
|
+
// or adjusted if it was the last row.
|
|
89
|
+
assert.strictEqual(state.dataCursor + state.dataOffset, 5, 'Table detail cursor should be at index 5 after deletion');
|
|
90
|
+
|
|
91
|
+
// Verify it's now 'Item 7' (since Item 6 was deleted)
|
|
92
|
+
const selectedRow = (navigator as any).getSelectedRow(state);
|
|
93
|
+
assert.strictEqual(selectedRow.name, 'Item 7');
|
|
94
|
+
});
|
|
95
|
+
});
|