dbn-cli 0.5.2 → 0.6.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/package.json +1 -1
- package/src/index.ts +10 -4
- package/src/types.ts +3 -0
- package/src/ui/grit/index.ts +6 -0
- package/src/ui/grit/utils.ts +2 -0
- package/src/ui/navigator.test.ts +12 -0
- package/src/ui/navigator.ts +26 -0
- package/src/ui/renderer.ts +39 -5
- package/src/ui/theme.ts +1 -1
- package/src/utils/clipboard.ts +56 -0
- package/src/utils/debounce.ts +16 -0
- package/src/utils/format.ts +15 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { SQLiteAdapter } from './adapter/sqlite.ts';
|
|
|
5
5
|
import { Screen } from './ui/screen.ts';
|
|
6
6
|
import { Renderer } from './ui/renderer.ts';
|
|
7
7
|
import { Navigator } from './ui/navigator.ts';
|
|
8
|
+
import { debounce } from './utils/debounce.ts';
|
|
8
9
|
import type { KeyPress } from './types.ts';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -72,10 +73,9 @@ export class DBPeek {
|
|
|
72
73
|
// Set up keyboard input
|
|
73
74
|
this.setupInput();
|
|
74
75
|
|
|
75
|
-
// Handle screen resize
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
});
|
|
76
|
+
// Handle screen resize with debounce to avoid flickering
|
|
77
|
+
const debouncedRender = debounce(() => this.render(), 50);
|
|
78
|
+
this.screen.on('resize', debouncedRender);
|
|
79
79
|
|
|
80
80
|
// Initial render
|
|
81
81
|
this.render();
|
|
@@ -198,6 +198,12 @@ export class DBPeek {
|
|
|
198
198
|
this.render();
|
|
199
199
|
break;
|
|
200
200
|
|
|
201
|
+
case 'c':
|
|
202
|
+
if (this.navigator.getState().type === 'row-detail') {
|
|
203
|
+
this.navigator.copyToClipboard(() => this.render()).then(() => this.render());
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
|
|
201
207
|
case 'backspace': {
|
|
202
208
|
const deleteState = this.navigator.getState();
|
|
203
209
|
if (deleteState.type === 'table-detail' || deleteState.type === 'row-detail') {
|
package/src/types.ts
CHANGED
|
@@ -125,6 +125,9 @@ export interface RowDetailViewState extends BaseViewState {
|
|
|
125
125
|
scrollOffset: number;
|
|
126
126
|
totalLines: number;
|
|
127
127
|
visibleHeight: number;
|
|
128
|
+
cachedLines?: string[];
|
|
129
|
+
cachedWidth?: number;
|
|
130
|
+
cachedRowIndex?: number;
|
|
128
131
|
deleteConfirm?: DeleteConfirmationState;
|
|
129
132
|
notice?: string;
|
|
130
133
|
}
|
package/src/ui/grit/index.ts
CHANGED
|
@@ -52,10 +52,16 @@ export class Box {
|
|
|
52
52
|
|
|
53
53
|
export class Transition {
|
|
54
54
|
static draw(width: number, topBg: Color, bottomBg: Color): string {
|
|
55
|
+
if (topBg === bottomBg) {
|
|
56
|
+
return `${ANSI.bg(bottomBg)}${' '.repeat(width)}${ANSI.reset}`;
|
|
57
|
+
}
|
|
55
58
|
return `${ANSI.fg(topBg)}${ANSI.bg(bottomBg)}${ANSI.blockUpper.repeat(width)}${ANSI.reset}`;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
static drawInverted(width: number, topBg: Color, bottomBg: Color): string {
|
|
62
|
+
if (topBg === bottomBg) {
|
|
63
|
+
return `${ANSI.bg(topBg)}${' '.repeat(width)}${ANSI.reset}`;
|
|
64
|
+
}
|
|
59
65
|
return `${ANSI.fg(bottomBg)}${ANSI.bg(topBg)}${ANSI.blockLower.repeat(width)}${ANSI.reset}`;
|
|
60
66
|
}
|
|
61
67
|
}
|
package/src/ui/grit/utils.ts
CHANGED
|
@@ -5,11 +5,13 @@ export const ANSI = {
|
|
|
5
5
|
inverse: '\x1b[7m',
|
|
6
6
|
// TrueColor (24-bit) foreground
|
|
7
7
|
fg: (hex: string) => {
|
|
8
|
+
if (!hex) return '\x1b[39m';
|
|
8
9
|
const { r, g, b } = hexToRgb(hex);
|
|
9
10
|
return `\x1b[38;2;${r};${g};${b}m`;
|
|
10
11
|
},
|
|
11
12
|
// TrueColor (24-bit) background
|
|
12
13
|
bg: (hex: string) => {
|
|
14
|
+
if (!hex) return '\x1b[49m';
|
|
13
15
|
const { r, g, b } = hexToRgb(hex);
|
|
14
16
|
return `\x1b[48;2;${r};${g};${b}m`;
|
|
15
17
|
},
|
package/src/ui/navigator.test.ts
CHANGED
|
@@ -294,6 +294,18 @@ describe('Navigator', () => {
|
|
|
294
294
|
const state = navigator.getState();
|
|
295
295
|
assert.strictEqual(state.type, 'table-detail');
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
it('should set notice when copying to clipboard', async () => {
|
|
299
|
+
navigator.init();
|
|
300
|
+
navigator.enter();
|
|
301
|
+
navigator.enter();
|
|
302
|
+
|
|
303
|
+
const state = navigator.getState() as any;
|
|
304
|
+
assert.strictEqual(state.type, 'row-detail');
|
|
305
|
+
|
|
306
|
+
await navigator.copyToClipboard();
|
|
307
|
+
assert.ok(state.notice === 'Copied to clipboard' || state.notice === 'Failed to copy to clipboard');
|
|
308
|
+
});
|
|
297
309
|
});
|
|
298
310
|
|
|
299
311
|
describe('delete flow', () => {
|
package/src/ui/navigator.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DatabaseAdapter } from '../adapter/base.ts';
|
|
2
2
|
import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState, HealthViewState, ColumnSchema } from '../types.ts';
|
|
3
3
|
import { getVisibleWidth, formatValue } from '../utils/format.ts';
|
|
4
|
+
import { copyToClipboard } from '../utils/clipboard.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Navigation state manager
|
|
@@ -15,6 +16,31 @@ export class Navigator {
|
|
|
15
16
|
this.adapter = adapter;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Copy current row to clipboard as JSON
|
|
21
|
+
* @param onUpdate - Callback called after notice is cleared to trigger a re-render
|
|
22
|
+
*/
|
|
23
|
+
async copyToClipboard(onUpdate?: () => void): Promise<void> {
|
|
24
|
+
const state = this.currentState;
|
|
25
|
+
if (state && state.type === 'row-detail') {
|
|
26
|
+
const json = JSON.stringify(state.row, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2);
|
|
27
|
+
const success = await copyToClipboard(json);
|
|
28
|
+
if (success) {
|
|
29
|
+
state.notice = 'Copied to clipboard';
|
|
30
|
+
} else {
|
|
31
|
+
state.notice = 'Failed to copy to clipboard';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Clear notice after 3 seconds
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
if (state.notice === 'Copied to clipboard' || state.notice === 'Failed to copy to clipboard') {
|
|
37
|
+
state.notice = undefined;
|
|
38
|
+
if (onUpdate) onUpdate();
|
|
39
|
+
}
|
|
40
|
+
}, 3000);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
/**
|
|
19
45
|
* Initialize navigator with tables list view
|
|
20
46
|
*/
|
package/src/ui/renderer.ts
CHANGED
|
@@ -10,6 +10,9 @@ import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState,
|
|
|
10
10
|
*/
|
|
11
11
|
export class Renderer {
|
|
12
12
|
private screen: Screen;
|
|
13
|
+
private lastLines: string[] = [];
|
|
14
|
+
private lastWidth: number = 0;
|
|
15
|
+
private lastHeight: number = 0;
|
|
13
16
|
|
|
14
17
|
constructor(screen: Screen) {
|
|
15
18
|
this.screen = screen;
|
|
@@ -38,9 +41,26 @@ export class Renderer {
|
|
|
38
41
|
lines.push(Transition.draw(width, THEME.background, THEME.footerBg));
|
|
39
42
|
lines.push(this.buildHelpBar(state, width));
|
|
40
43
|
|
|
41
|
-
//
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
+
// Incremental Render Logic
|
|
45
|
+
if (width !== this.lastWidth || height !== this.lastHeight) {
|
|
46
|
+
// Screen resized: Full redraw from top, then clear remainder of screen
|
|
47
|
+
this.screen.moveCursor(1, 1);
|
|
48
|
+
this.screen.write(lines.join('\n'));
|
|
49
|
+
this.screen.write('\x1b[J'); // Clear remaining lines if new height is smaller
|
|
50
|
+
} else {
|
|
51
|
+
// Incremental update: Only write changed lines
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
if (lines[i] !== this.lastLines[i]) {
|
|
54
|
+
this.screen.moveCursor(i + 1, 1);
|
|
55
|
+
// Write line and clear to end of line to prevent ghosting
|
|
56
|
+
this.screen.write(lines[i] + '\x1b[K');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.lastLines = lines;
|
|
62
|
+
this.lastWidth = width;
|
|
63
|
+
this.lastHeight = height;
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
private buildTitleBar(state: ViewState, dbPath: string, width: number): string {
|
|
@@ -216,11 +236,18 @@ export class Renderer {
|
|
|
216
236
|
}
|
|
217
237
|
|
|
218
238
|
private renderRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
|
|
219
|
-
const allLines: string[] = [];
|
|
220
|
-
const { row, schema } = state;
|
|
221
239
|
const innerWidth = width - 2;
|
|
222
240
|
const box = new Box({ width, padding: 1, background: THEME.background });
|
|
223
241
|
|
|
242
|
+
// Check cache
|
|
243
|
+
if (state.cachedLines && state.cachedWidth === width && state.cachedRowIndex === state.rowIndex) {
|
|
244
|
+
state.visibleHeight = height;
|
|
245
|
+
return state.cachedLines.slice(state.scrollOffset, state.scrollOffset + height);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const allLines: string[] = [];
|
|
249
|
+
const { row, schema } = state;
|
|
250
|
+
|
|
224
251
|
// Calculate max label width for alignment
|
|
225
252
|
let maxLabelWidth = 0;
|
|
226
253
|
schema.forEach(col => {
|
|
@@ -258,6 +285,9 @@ export class Renderer {
|
|
|
258
285
|
}
|
|
259
286
|
});
|
|
260
287
|
|
|
288
|
+
state.cachedLines = allLines;
|
|
289
|
+
state.cachedWidth = width;
|
|
290
|
+
state.cachedRowIndex = state.rowIndex;
|
|
261
291
|
state.totalLines = allLines.length;
|
|
262
292
|
state.visibleHeight = height;
|
|
263
293
|
|
|
@@ -291,6 +321,7 @@ export class Renderer {
|
|
|
291
321
|
case 'tables':
|
|
292
322
|
helpItems = [
|
|
293
323
|
{ key: 'j/k', label: 'select' },
|
|
324
|
+
{ key: 'g/G', label: 'first/last' },
|
|
294
325
|
{ key: 'Enter/l', label: 'open' },
|
|
295
326
|
{ key: 'i', label: 'info' },
|
|
296
327
|
{ key: 'q', label: 'quit' }
|
|
@@ -299,6 +330,7 @@ export class Renderer {
|
|
|
299
330
|
case 'table-detail':
|
|
300
331
|
helpItems = [
|
|
301
332
|
{ key: 'j/k', label: 'scroll' },
|
|
333
|
+
{ key: 'g/G', label: 'first/last' },
|
|
302
334
|
{ key: 'Enter/l', label: 'row' },
|
|
303
335
|
{ key: 's', label: 'schema' },
|
|
304
336
|
{ key: 'h', label: 'back' },
|
|
@@ -308,6 +340,7 @@ export class Renderer {
|
|
|
308
340
|
case 'schema-view':
|
|
309
341
|
helpItems = [
|
|
310
342
|
{ key: 'j/k', label: 'scroll' },
|
|
343
|
+
{ key: 'g/G', label: 'first/last' },
|
|
311
344
|
{ key: 's/h', label: 'back' },
|
|
312
345
|
{ key: 'q', label: 'quit' }
|
|
313
346
|
];
|
|
@@ -316,6 +349,7 @@ export class Renderer {
|
|
|
316
349
|
helpItems = [
|
|
317
350
|
{ key: 'j/k', label: 'switch' },
|
|
318
351
|
{ key: '↑/↓', label: 'scroll' },
|
|
352
|
+
{ key: 'c', label: 'copy' },
|
|
319
353
|
{ key: 'h', label: 'back' },
|
|
320
354
|
{ key: 'q', label: 'quit' }
|
|
321
355
|
];
|
package/src/ui/theme.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Copy text to system clipboard
|
|
6
|
+
* Supports macOS (pbcopy), Windows (clip), and Linux (xclip/xsel)
|
|
7
|
+
*/
|
|
8
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
9
|
+
const os = platform();
|
|
10
|
+
let command = '';
|
|
11
|
+
let args: string[] = [];
|
|
12
|
+
|
|
13
|
+
if (os === 'darwin') {
|
|
14
|
+
command = 'pbcopy';
|
|
15
|
+
} else if (os === 'win32') {
|
|
16
|
+
command = 'clip';
|
|
17
|
+
} else if (os === 'linux') {
|
|
18
|
+
// Try xclip first
|
|
19
|
+
command = 'xclip';
|
|
20
|
+
args = ['-selection', 'clipboard'];
|
|
21
|
+
} else {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
try {
|
|
27
|
+
const child = spawn(command, args);
|
|
28
|
+
|
|
29
|
+
child.on('error', () => {
|
|
30
|
+
if (os === 'linux' && command === 'xclip') {
|
|
31
|
+
// Fallback to xsel if xclip fails
|
|
32
|
+
try {
|
|
33
|
+
const fallback = spawn('xsel', ['--clipboard', '--input']);
|
|
34
|
+
fallback.on('error', () => resolve(false));
|
|
35
|
+
fallback.stdin.write(text);
|
|
36
|
+
fallback.stdin.end();
|
|
37
|
+
fallback.on('exit', (code) => resolve(code === 0));
|
|
38
|
+
} catch {
|
|
39
|
+
resolve(false);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
resolve(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.stdin.write(text);
|
|
47
|
+
child.stdin.end();
|
|
48
|
+
|
|
49
|
+
child.on('exit', (code) => {
|
|
50
|
+
resolve(code === 0);
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
resolve(false);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debounce a function
|
|
3
|
+
*/
|
|
4
|
+
export function debounce<T extends (...args: any[]) => any>(
|
|
5
|
+
fn: T,
|
|
6
|
+
ms: number
|
|
7
|
+
): (...args: Parameters<T>) => void {
|
|
8
|
+
let timeoutId: NodeJS.Timeout | null = null;
|
|
9
|
+
return (...args: Parameters<T>) => {
|
|
10
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
11
|
+
timeoutId = setTimeout(() => {
|
|
12
|
+
fn(...args);
|
|
13
|
+
timeoutId = null;
|
|
14
|
+
}, ms);
|
|
15
|
+
};
|
|
16
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -154,6 +154,21 @@ export function wrapText(text: string, maxWidth: number): string[] {
|
|
|
154
154
|
continue;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// Fast path for ASCII-only strings (no ANSI, no CJK/Emoji)
|
|
158
|
+
// Most JSON and technical data falls here.
|
|
159
|
+
// We include \t \r \n (though split by \n already) and space.
|
|
160
|
+
if (maxWidth > 0 && /^[\x20-\x7E\t\r\n]*$/.test(sourceLine)) {
|
|
161
|
+
if (sourceLine.length <= maxWidth) {
|
|
162
|
+
lines.push(sourceLine);
|
|
163
|
+
} else {
|
|
164
|
+
for (let i = 0; i < sourceLine.length; i += maxWidth) {
|
|
165
|
+
lines.push(sourceLine.slice(i, i + maxWidth));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Slow path for complex strings (CJK, Emoji, ANSI)
|
|
157
172
|
let currentLine = '';
|
|
158
173
|
let currentWidth = 0;
|
|
159
174
|
|