dbn-cli 0.2.4 → 0.4.0

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 CHANGED
@@ -1,4 +1,4 @@
1
- # dbn
1
+ # dbn - DB Navigator
2
2
 
3
3
  A lightweight terminal SQLite browser with an ncdu-style interface.
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbn-cli",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "A lightweight terminal-based database browser",
5
5
  "repository": "https://github.com/amio/dbn-cli",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import type { TableInfo, ColumnSchema, QueryOptions } from '../types.ts';
1
+ import type { TableInfo, ColumnSchema, QueryOptions, HealthInfo } from '../types.ts';
2
2
 
3
3
  /**
4
4
  * Base class for database adapters
@@ -39,6 +39,19 @@ export abstract class DatabaseAdapter {
39
39
  */
40
40
  abstract getRowCount(tableName: string): number;
41
41
 
42
+ /**
43
+ * Delete a single row from a table by its primary key values
44
+ * @param tableName - Name of the table
45
+ * @param keyValues - Primary key column/value mapping
46
+ */
47
+ abstract deleteRow(tableName: string, keyValues: Record<string, any>): void;
48
+
49
+ /**
50
+ * Get core database health info
51
+ * @returns Health information
52
+ */
53
+ abstract getHealthInfo(): HealthInfo;
54
+
42
55
  /**
43
56
  * Close the database connection
44
57
  */
@@ -1,6 +1,6 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { DatabaseAdapter } from './base.ts';
3
- import type { TableInfo, ColumnSchema, QueryOptions } from '../types.ts';
3
+ import type { TableInfo, ColumnSchema, QueryOptions, HealthInfo } from '../types.ts';
4
4
 
5
5
  /**
6
6
  * SQLite adapter using Node.js 22+ built-in node:sqlite
@@ -98,6 +98,76 @@ export class SQLiteAdapter extends DatabaseAdapter {
98
98
  return result.count;
99
99
  }
100
100
 
101
+ /**
102
+ * Delete a single row from a table by its primary key values
103
+ * @param tableName - Name of the table
104
+ * @param keyValues - Primary key column/value mapping
105
+ */
106
+ deleteRow(tableName: string, keyValues: Record<string, any>): void {
107
+ if (!this.db) {
108
+ throw new Error('Database not connected');
109
+ }
110
+
111
+ const keys = Object.keys(keyValues);
112
+ if (keys.length === 0) {
113
+ throw new Error(`No primary key values provided for delete on ${tableName}`);
114
+ }
115
+
116
+ const conditions: string[] = [];
117
+ const values: any[] = [];
118
+
119
+ for (const key of keys) {
120
+ const value = keyValues[key];
121
+ if (value === null || value === undefined) {
122
+ conditions.push(`"${key}" IS NULL`);
123
+ } else {
124
+ conditions.push(`"${key}" = ?`);
125
+ values.push(value);
126
+ }
127
+ }
128
+
129
+ const whereClause = conditions.join(' AND ');
130
+ const stmt = this.db.prepare(`DELETE FROM "${tableName}" WHERE ${whereClause}`);
131
+ stmt.run(...values);
132
+ }
133
+
134
+ /**
135
+ * Get core database health info
136
+ */
137
+ getHealthInfo(): HealthInfo {
138
+ if (!this.db) {
139
+ throw new Error('Database not connected');
140
+ }
141
+
142
+ const singleValue = <T = string>(sql: string): T => {
143
+ const stmt = this.db!.prepare(sql);
144
+ const row = stmt.get() as Record<string, any> | undefined;
145
+ if (!row) return '' as T;
146
+ const value = Object.values(row)[0];
147
+ return value as T;
148
+ };
149
+
150
+ return {
151
+ sqlite_version: singleValue<string>('SELECT sqlite_version()'),
152
+ journal_mode: singleValue<string>('PRAGMA journal_mode'),
153
+ synchronous: String(singleValue<number>('PRAGMA synchronous')),
154
+ locking_mode: singleValue<string>('PRAGMA locking_mode'),
155
+ page_size: singleValue<number>('PRAGMA page_size'),
156
+ page_count: singleValue<number>('PRAGMA page_count'),
157
+ freelist_count: singleValue<number>('PRAGMA freelist_count'),
158
+ cache_size: singleValue<number>('PRAGMA cache_size'),
159
+ wal_autocheckpoint: singleValue<number>('PRAGMA wal_autocheckpoint'),
160
+ auto_vacuum: String(singleValue<number>('PRAGMA auto_vacuum')),
161
+ user_version: singleValue<number>('PRAGMA user_version'),
162
+ application_id: singleValue<number>('PRAGMA application_id'),
163
+ encoding: singleValue<string>('PRAGMA encoding'),
164
+ foreign_keys: String(singleValue<number>('PRAGMA foreign_keys')),
165
+ temp_store: String(singleValue<number>('PRAGMA temp_store')),
166
+ mmap_size: singleValue<number>('PRAGMA mmap_size'),
167
+ busy_timeout: singleValue<number>('PRAGMA busy_timeout')
168
+ };
169
+ }
170
+
101
171
  /**
102
172
  * Close the database connection
103
173
  */
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { stdin, stdout, exit } from 'node:process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import * as readline from 'node:readline';
4
4
  import { SQLiteAdapter } from './adapter/sqlite.ts';
5
5
  import { Screen } from './ui/screen.ts';
@@ -112,6 +112,25 @@ export class DBPeek {
112
112
  return;
113
113
  }
114
114
 
115
+ if (key.name === 'y' && this.navigator.hasPendingDelete()) {
116
+ this.navigator.confirmDelete();
117
+ this.render();
118
+ return;
119
+ }
120
+
121
+ if (
122
+ this.navigator.hasPendingDelete() &&
123
+ (key.name === 'escape' || key.name === 'h' || key.name === 'left')
124
+ ) {
125
+ this.navigator.cancelDelete();
126
+ this.render();
127
+ return;
128
+ }
129
+
130
+ if (this.navigator.hasPendingDelete()) {
131
+ return;
132
+ }
133
+
115
134
  // Handle different keys
116
135
  switch (key.name) {
117
136
  case 'q':
@@ -161,6 +180,15 @@ export class DBPeek {
161
180
  this.render();
162
181
  break;
163
182
 
183
+ case 'backspace': {
184
+ const deleteState = this.navigator.getState();
185
+ if (deleteState.type === 'table-detail' || deleteState.type === 'row-detail') {
186
+ this.navigator.requestDelete();
187
+ this.render();
188
+ }
189
+ break;
190
+ }
191
+
164
192
  case 's':
165
193
  // Toggle schema view in full screen mode
166
194
  const currentState = this.navigator.getState();
@@ -172,6 +200,17 @@ export class DBPeek {
172
200
  this.render();
173
201
  }
174
202
  break;
203
+
204
+ case 'i':
205
+ // Toggle core health overview
206
+ const infoState = this.navigator.getState();
207
+ if (infoState.type === 'health') {
208
+ this.navigator.back();
209
+ } else if (infoState.type === 'tables') {
210
+ this.navigator.viewHealth();
211
+ }
212
+ this.render();
213
+ break;
175
214
  }
176
215
  }
177
216
 
@@ -227,14 +266,42 @@ export class DBPeek {
227
266
  * Main entry point
228
267
  */
229
268
  export function main(args: string[]): void {
230
- const dbPath = args[0];
269
+ // Simple CLI parsing: support flags (-v/--version, -h/--help)
270
+ const flags = new Set(args.filter(a => a.startsWith('-')));
271
+ const dbPath = args.find(a => !a.startsWith('-'));
272
+
273
+ const printHelp = () => {
274
+ stdout.write(`Usage: dbn [options] <path-to-sqlite-db-file>\n\n`);
275
+ stdout.write(`Options:\n`);
276
+ stdout.write(` -h, --help Show help information\n`);
277
+ stdout.write(` -v, --version Show version\n\n`);
278
+ stdout.write(`Example:\n`);
279
+ stdout.write(` dbn ./mydatabase.db\n`);
280
+ };
281
+
282
+ const printVersion = () => {
283
+ try {
284
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: string };
285
+ stdout.write((pkg.version ?? 'unknown') + '\n');
286
+ } catch {
287
+ stdout.write('unknown\n');
288
+ }
289
+ };
231
290
 
291
+ if (flags.has('-h') || flags.has('--help')) {
292
+ printHelp();
293
+ exit(0);
294
+ }
295
+
296
+ if (flags.has('-v') || flags.has('--version')) {
297
+ printVersion();
298
+ exit(0);
299
+ }
300
+
301
+ // If no db path provided, show help by default
232
302
  if (!dbPath) {
233
- console.error('Usage: dbn <path-to-sqlite-db-file>');
234
- console.error('');
235
- console.error('Example:');
236
- console.error(' dbn ./mydatabase.db');
237
- exit(1);
303
+ printHelp();
304
+ exit(0);
238
305
  }
239
306
 
240
307
  const app = new DBPeek(dbPath);
package/src/types.ts CHANGED
@@ -30,6 +30,39 @@ export interface QueryOptions {
30
30
  offset?: number;
31
31
  }
32
32
 
33
+ /**
34
+ * Core database health overview info
35
+ */
36
+ export interface HealthInfo {
37
+ sqlite_version: string;
38
+ journal_mode: string;
39
+ synchronous: string;
40
+ locking_mode: string;
41
+ page_size: number;
42
+ page_count: number;
43
+ freelist_count: number;
44
+ cache_size: number;
45
+ wal_autocheckpoint: number;
46
+ auto_vacuum: string;
47
+ user_version: number;
48
+ application_id: number;
49
+ encoding: string;
50
+ foreign_keys: string;
51
+ temp_store: string;
52
+ mmap_size: number;
53
+ busy_timeout: number;
54
+ }
55
+
56
+ /**
57
+ * Delete confirmation state for write actions
58
+ */
59
+ export interface DeleteConfirmationState {
60
+ tableName: string;
61
+ rowIndex: number;
62
+ keyValues: Record<string, any>;
63
+ step: 1 | 2;
64
+ }
65
+
33
66
  /**
34
67
  * Base view state properties
35
68
  */
@@ -60,6 +93,8 @@ export interface TableDetailViewState extends BaseViewState {
60
93
  dataCursor: number;
61
94
  visibleRows: number;
62
95
  showSchema?: boolean;
96
+ deleteConfirm?: DeleteConfirmationState;
97
+ notice?: string;
63
98
  }
64
99
 
65
100
  /**
@@ -82,12 +117,22 @@ export interface RowDetailViewState extends BaseViewState {
82
117
  row: Record<string, any>;
83
118
  rowIndex: number;
84
119
  schema: ColumnSchema[];
120
+ deleteConfirm?: DeleteConfirmationState;
121
+ notice?: string;
122
+ }
123
+
124
+ /**
125
+ * Health overview view state
126
+ */
127
+ export interface HealthViewState extends BaseViewState {
128
+ type: 'health';
129
+ info: HealthInfo;
85
130
  }
86
131
 
87
132
  /**
88
133
  * Union type for all possible view states
89
134
  */
90
- export type ViewState = TablesViewState | TableDetailViewState | SchemaViewState | RowDetailViewState;
135
+ export type ViewState = TablesViewState | TableDetailViewState | SchemaViewState | RowDetailViewState | HealthViewState;
91
136
 
92
137
  /**
93
138
  * Screen dimensions
@@ -1,5 +1,5 @@
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 } from '../types.ts';
3
3
 
4
4
  /**
5
5
  * Navigation state manager
@@ -220,6 +220,179 @@ export class Navigator {
220
220
  }
221
221
  }
222
222
 
223
+ /**
224
+ * View core health overview
225
+ */
226
+ viewHealth(): void {
227
+ const state = this.currentState;
228
+ if (!state || state.type !== 'tables') return;
229
+
230
+ const info = this.adapter.getHealthInfo();
231
+ const newState: HealthViewState = {
232
+ type: 'health',
233
+ info
234
+ };
235
+
236
+ this.states.push(newState);
237
+ this.currentState = newState;
238
+ }
239
+
240
+ /**
241
+ * Request delete for the currently selected row
242
+ */
243
+ requestDelete(): void {
244
+ const state = this.currentState;
245
+ if (!state) return;
246
+
247
+ if (state.type === 'table-detail') {
248
+ 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];
255
+ if (!selectedRow) {
256
+ state.notice = 'No row selected';
257
+ return;
258
+ }
259
+
260
+ const result = this.getPrimaryKeyValues(state.schema, selectedRow);
261
+ if ('error' in result) {
262
+ state.notice = result.error;
263
+ return;
264
+ }
265
+
266
+ state.deleteConfirm = {
267
+ tableName: state.tableName,
268
+ rowIndex: state.dataOffset + state.dataCursor,
269
+ keyValues: result.keyValues,
270
+ step: 1
271
+ };
272
+ state.notice = `Delete row ${state.dataOffset + state.dataCursor + 1}? Press y`;
273
+ } else if (state.type === 'row-detail') {
274
+ state.deleteConfirm = undefined;
275
+ const result = this.getPrimaryKeyValues(state.schema, state.row);
276
+ if ('error' in result) {
277
+ state.notice = result.error;
278
+ return;
279
+ }
280
+
281
+ state.deleteConfirm = {
282
+ tableName: state.tableName,
283
+ rowIndex: state.rowIndex,
284
+ keyValues: result.keyValues,
285
+ step: 1
286
+ };
287
+ state.notice = `Delete row ${state.rowIndex + 1}? Press y`;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Confirm delete (multi-step)
293
+ */
294
+ confirmDelete(): void {
295
+ const state = this.currentState;
296
+ if (!state) return;
297
+
298
+ if (state.type === 'table-detail' && state.deleteConfirm) {
299
+ const confirm = state.deleteConfirm;
300
+
301
+ if (confirm.step === 1) {
302
+ state.deleteConfirm = { ...confirm, step: 2 };
303
+ state.notice = `Delete row ${confirm.rowIndex + 1}? Press y again`;
304
+ return;
305
+ }
306
+
307
+ try {
308
+ this.adapter.deleteRow(confirm.tableName, confirm.keyValues);
309
+ } catch (error) {
310
+ state.notice = `Delete failed: ${(error as Error).message}`;
311
+ state.deleteConfirm = undefined;
312
+ return;
313
+ }
314
+
315
+ state.deleteConfirm = undefined;
316
+ state.notice = `Row ${confirm.rowIndex + 1} deleted`;
317
+ state.totalRows = this.adapter.getRowCount(state.tableName);
318
+
319
+ const maxOffset = Math.max(0, state.totalRows - state.visibleRows);
320
+ state.dataOffset = Math.min(state.dataOffset, maxOffset);
321
+
322
+ this.reload();
323
+ if (state.dataCursor >= state.data.length) {
324
+ state.dataCursor = Math.max(0, state.data.length - 1);
325
+ }
326
+ } else if (state.type === 'row-detail' && state.deleteConfirm) {
327
+ const confirm = state.deleteConfirm;
328
+
329
+ if (confirm.step === 1) {
330
+ state.deleteConfirm = { ...confirm, step: 2 };
331
+ state.notice = `Delete row ${confirm.rowIndex + 1}? Press y again`;
332
+ return;
333
+ }
334
+
335
+ try {
336
+ this.adapter.deleteRow(confirm.tableName, confirm.keyValues);
337
+ } catch (error) {
338
+ state.notice = `Delete failed: ${(error as Error).message}`;
339
+ state.deleteConfirm = undefined;
340
+ return;
341
+ }
342
+
343
+ const parent = this.states[this.states.length - 2];
344
+ if (parent && parent.type === 'table-detail') {
345
+ parent.notice = `Row ${confirm.rowIndex + 1} deleted`;
346
+ parent.totalRows = this.adapter.getRowCount(parent.tableName);
347
+
348
+ const maxOffset = Math.max(0, parent.totalRows - parent.visibleRows);
349
+ parent.dataOffset = Math.min(parent.dataOffset, maxOffset);
350
+
351
+ parent.deleteConfirm = undefined;
352
+ this.states.pop();
353
+ this.currentState = parent;
354
+ this.reload();
355
+
356
+ if (parent.dataCursor >= parent.data.length) {
357
+ parent.dataCursor = Math.max(0, parent.data.length - 1);
358
+ }
359
+ } else {
360
+ state.notice = `Row ${confirm.rowIndex + 1} deleted`;
361
+ state.deleteConfirm = undefined;
362
+ }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Cancel delete confirmation
368
+ */
369
+ cancelDelete(): void {
370
+ const state = this.currentState;
371
+ if (!state) return;
372
+
373
+ if (state.type === 'table-detail' && state.deleteConfirm) {
374
+ state.deleteConfirm = undefined;
375
+ state.notice = 'Delete cancelled';
376
+ } else if (state.type === 'row-detail' && state.deleteConfirm) {
377
+ state.deleteConfirm = undefined;
378
+ state.notice = 'Delete cancelled';
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Check if the current view is awaiting delete confirmation
384
+ */
385
+ hasPendingDelete(): boolean {
386
+ const state = this.currentState;
387
+ if (!state) return false;
388
+
389
+ if (state.type === 'table-detail' || state.type === 'row-detail') {
390
+ return Boolean(state.deleteConfirm);
391
+ }
392
+
393
+ return false;
394
+ }
395
+
223
396
  /**
224
397
  * Reload current view data
225
398
  */
@@ -236,6 +409,28 @@ export class Navigator {
236
409
  }
237
410
  }
238
411
 
412
+ private getPrimaryKeyValues(
413
+ schema: TableDetailViewState['schema'],
414
+ row: Record<string, any>
415
+ ): { keyValues: Record<string, any> } | { error: string } {
416
+ const pkColumns = schema
417
+ .filter(col => col.pk)
418
+ .sort((a, b) => a.pk - b.pk);
419
+ if (pkColumns.length === 0) {
420
+ return { error: 'Cannot delete: table has no primary key' };
421
+ }
422
+
423
+ const keyValues: Record<string, any> = {};
424
+ for (const col of pkColumns) {
425
+ if (!(col.name in row)) {
426
+ return { error: `Cannot delete: missing primary key value for ${col.name}` };
427
+ }
428
+ keyValues[col.name] = row[col.name];
429
+ }
430
+
431
+ return { keyValues };
432
+ }
433
+
239
434
  /**
240
435
  * Get breadcrumb path
241
436
  * @returns Breadcrumb path string
@@ -1,7 +1,7 @@
1
1
  import { COLORS, BORDERS, UI } from './theme.ts';
2
2
  import { formatNumber, truncate, pad, formatValue, getVisibleWidth } from '../utils/format.ts';
3
3
  import type { Screen } from './screen.ts';
4
- import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState } from '../types.ts';
4
+ import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState, HealthViewState } from '../types.ts';
5
5
 
6
6
  /**
7
7
  * Renderer for ncdu-style TUI
@@ -49,6 +49,8 @@ export class Renderer {
49
49
  title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName} ${COLORS.dim}> schema${COLORS.reset}`;
50
50
  } else if (state.type === 'row-detail') {
51
51
  title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName} ${COLORS.dim}> row ${state.rowIndex + 1}${COLORS.reset}`;
52
+ } else if (state.type === 'health') {
53
+ title += ` ${COLORS.dim}>${COLORS.reset} health`;
52
54
  }
53
55
 
54
56
  title = `${COLORS.bold}${title}${COLORS.reset}`;
@@ -70,6 +72,8 @@ export class Renderer {
70
72
  } else if (state.type === 'row-detail') {
71
73
  const colCount = state.schema.length;
72
74
  rightInfo = `${COLORS.dim}${colCount} fields${COLORS.reset}`;
75
+ } else if (state.type === 'health') {
76
+ rightInfo = `${COLORS.dim}overview${COLORS.reset}`;
73
77
  }
74
78
 
75
79
  // Calculate spacing using visible width (accounts for CJK double-width)
@@ -122,6 +126,8 @@ export class Renderer {
122
126
  return this.buildSchemaView(state, height, width);
123
127
  } else if (state.type === 'row-detail') {
124
128
  return this.buildRowDetail(state, height, width);
129
+ } else if (state.type === 'health') {
130
+ return this.buildHealthView(state, height, width);
125
131
  }
126
132
  return [];
127
133
  }
@@ -477,20 +483,100 @@ export class Renderer {
477
483
  return lines;
478
484
  }
479
485
 
486
+ /**
487
+ * Build health overview view
488
+ */
489
+ private buildHealthView(state: HealthViewState, height: number, width: number): string[] {
490
+ const lines: string[] = [];
491
+ const { info } = state;
492
+
493
+ const title = `${COLORS.bold}Core health overview${COLORS.reset}`;
494
+ lines.push(pad(title, width));
495
+
496
+ const formatBool = (value: string): string => (value === '1' ? 'on' : value === '0' ? 'off' : value);
497
+ const formatAutoVacuum = (value: string): string => {
498
+ if (value === '0') return 'none';
499
+ if (value === '1') return 'full';
500
+ if (value === '2') return 'incremental';
501
+ return value;
502
+ };
503
+
504
+ const entries: Array<[string, string]> = [
505
+ ['SQLite version', info.sqlite_version],
506
+ ['Journal mode', info.journal_mode],
507
+ ['Synchronous', info.synchronous],
508
+ ['Locking mode', info.locking_mode],
509
+ ['Page size', formatNumber(info.page_size)],
510
+ ['Page count', formatNumber(info.page_count)],
511
+ ['Freelist pages', formatNumber(info.freelist_count)],
512
+ ['Cache size', formatNumber(info.cache_size)],
513
+ ['WAL autocheckpoint', formatNumber(info.wal_autocheckpoint)],
514
+ ['Auto vacuum', formatAutoVacuum(info.auto_vacuum)],
515
+ ['User version', String(info.user_version)],
516
+ ['Application id', String(info.application_id)],
517
+ ['Encoding', info.encoding],
518
+ ['Foreign keys', formatBool(info.foreign_keys)],
519
+ ['Temp store', info.temp_store],
520
+ ['Mmap size', formatNumber(info.mmap_size)],
521
+ ['Busy timeout', `${formatNumber(info.busy_timeout)} ms`]
522
+ ];
523
+
524
+ const labelWidth = Math.min(24, Math.max(...entries.map(([label]) => label.length)) + 1);
525
+ const contentWidth = width - labelWidth - 4;
526
+
527
+ for (const [label, value] of entries) {
528
+ if (lines.length >= height) break;
529
+ const labelText = pad(label, labelWidth, 'right');
530
+ const valueText = truncate(value, contentWidth);
531
+ const line = ` ${COLORS.cyan}${labelText}${COLORS.reset} ${COLORS.dim}:${COLORS.reset} ${valueText}`;
532
+ const lineWidth = getVisibleWidth(line);
533
+ if (lineWidth < width) {
534
+ lines.push(line + ' '.repeat(width - lineWidth));
535
+ } else {
536
+ const basicLine = ` ${labelText} : ${valueText}`;
537
+ lines.push(truncate(basicLine, width - 1));
538
+ }
539
+ }
540
+
541
+ while (lines.length < height) {
542
+ lines.push(' '.repeat(width));
543
+ }
544
+
545
+ return lines;
546
+ }
547
+
480
548
  /**
481
549
  * Build help bar (bottom line)
482
550
  */
483
551
  private buildHelpBar(state: ViewState, width: number): string {
484
552
  let help = '';
553
+ const notice =
554
+ (state.type === 'table-detail' || state.type === 'row-detail') ? state.notice : undefined;
555
+ const deleteStep =
556
+ (state.type === 'table-detail' || state.type === 'row-detail') ? state.deleteConfirm?.step : undefined;
557
+ const isDeleteConfirm =
558
+ (state.type === 'table-detail' || state.type === 'row-detail') ? Boolean(state.deleteConfirm) : false;
485
559
 
486
- if (state.type === 'tables') {
487
- help = ' [j/k] select [Enter/l] open [g/G] top/bottom [q] quit';
560
+ if (notice && isDeleteConfirm) {
561
+ const stepLabel = deleteStep ? `Step ${deleteStep}/2` : '';
562
+ const actionLabel = deleteStep === 2 ? 'delete' : 'confirm';
563
+ help = ` ${COLORS.red}${COLORS.bold}${stepLabel ? `${stepLabel} ` : ''}${notice}${COLORS.reset} ${COLORS.yellow}[y] ${actionLabel}${COLORS.reset} [h/Esc] cancel`;
564
+ const paddedHelp = pad(help, width);
565
+ return paddedHelp;
566
+ } else if (notice) {
567
+ help = ` ${COLORS.yellow}${notice}${COLORS.reset}`;
568
+ const paddedHelp = pad(help, width);
569
+ return paddedHelp;
570
+ } else if (state.type === 'tables') {
571
+ help = ' [j/k] select [Enter/l] open [i] info [g/G] top/bottom [q] quit';
488
572
  } else if (state.type === 'table-detail') {
489
- help = ' [j/k] scroll [Enter/l] view row [s] toggle schema [h/Esc] back [q] quit';
573
+ help = ' [j/k] scroll [Enter/l] view row [Backspace] delete [s] toggle schema [h/Esc] back [q] quit';
490
574
  } else if (state.type === 'schema-view') {
491
575
  help = ' [j/k] scroll [g/G] top/bottom [s/h/Esc] back [q] quit';
492
576
  } else if (state.type === 'row-detail') {
493
- help = ' [h/Esc] back [q] quit';
577
+ help = ' [Backspace] delete [h/Esc] back [q] quit';
578
+ } else if (state.type === 'health') {
579
+ help = ' [i] back [h/Esc] back [q] quit';
494
580
  }
495
581
 
496
582
  const paddedHelp = pad(help, width);