dbn-cli 0.4.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.
@@ -1,5 +1,6 @@
1
1
  import type { DatabaseAdapter } from '../adapter/base.ts';
2
- import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState, HealthViewState } 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: 100, offset: 0 });
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
- if (state.data.length > 0) {
196
- const selectedRow = state.data[state.dataCursor];
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
- schema: state.schema
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
  */
@@ -246,12 +343,7 @@ export class Navigator {
246
343
 
247
344
  if (state.type === 'table-detail') {
248
345
  state.deleteConfirm = undefined;
249
- if (state.data.length === 0) {
250
- state.notice = 'No row selected';
251
- return;
252
- }
253
-
254
- const selectedRow = state.data[state.dataCursor];
346
+ const selectedRow = this.getSelectedRow(state);
255
347
  if (!selectedRow) {
256
348
  state.notice = 'No row selected';
257
349
  return;
@@ -316,10 +408,11 @@ export class Navigator {
316
408
  state.notice = `Row ${confirm.rowIndex + 1} deleted`;
317
409
  state.totalRows = this.adapter.getRowCount(state.tableName);
318
410
 
319
- const maxOffset = Math.max(0, state.totalRows - state.visibleRows);
411
+ const visibleRows = state.visibleRows || 20;
412
+ const maxOffset = Math.max(0, state.totalRows - visibleRows);
320
413
  state.dataOffset = Math.min(state.dataOffset, maxOffset);
321
414
 
322
- this.reload();
415
+ this.reload(true);
323
416
  if (state.dataCursor >= state.data.length) {
324
417
  state.dataCursor = Math.max(0, state.data.length - 1);
325
418
  }
@@ -345,13 +438,16 @@ export class Navigator {
345
438
  parent.notice = `Row ${confirm.rowIndex + 1} deleted`;
346
439
  parent.totalRows = this.adapter.getRowCount(parent.tableName);
347
440
 
441
+ // Sync parent cursor to the deleted row's position
442
+ this.syncParentState(confirm.rowIndex);
443
+
348
444
  const maxOffset = Math.max(0, parent.totalRows - parent.visibleRows);
349
445
  parent.dataOffset = Math.min(parent.dataOffset, maxOffset);
350
446
 
351
447
  parent.deleteConfirm = undefined;
352
448
  this.states.pop();
353
449
  this.currentState = parent;
354
- this.reload();
450
+ this.reload(true);
355
451
 
356
452
  if (parent.dataCursor >= parent.data.length) {
357
453
  parent.dataCursor = Math.max(0, parent.data.length - 1);
@@ -396,19 +492,86 @@ export class Navigator {
396
492
  /**
397
493
  * Reload current view data
398
494
  */
399
- reload(): void {
400
- const state = this.currentState;
401
-
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 {
402
505
  if (state && state.type === 'table-detail') {
403
- // Reload table data with current offset
404
- const loadOffset = Math.max(0, state.dataOffset);
405
- state.data = this.adapter.getTableData(state.tableName, {
406
- limit: 100,
407
- offset: loadOffset
408
- });
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
+ }
409
529
  }
410
530
  }
411
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
+
412
575
  private getPrimaryKeyValues(
413
576
  schema: TableDetailViewState['schema'],
414
577
  row: Record<string, any>
@@ -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
+ });