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 +1 -1
- package/package.json +1 -1
- package/src/adapter/base.ts +14 -1
- package/src/adapter/sqlite.ts +71 -1
- package/src/index.ts +74 -7
- package/src/types.ts +46 -1
- package/src/ui/navigator.ts +196 -1
- package/src/ui/renderer.ts +91 -5
package/README.md
CHANGED
package/package.json
CHANGED
package/src/adapter/base.ts
CHANGED
|
@@ -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
|
*/
|
package/src/adapter/sqlite.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
package/src/ui/navigator.ts
CHANGED
|
@@ -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
|
package/src/ui/renderer.ts
CHANGED
|
@@ -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 (
|
|
487
|
-
|
|
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);
|