dbn-cli 0.5.3 → 0.6.1
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 +2 -0
- package/package.json +2 -2
- package/src/index.ts +6 -0
- package/src/types.ts +3 -0
- package/src/ui/navigator.test.ts +12 -0
- package/src/ui/navigator.ts +26 -0
- package/src/ui/renderer.ts +13 -2
- package/src/utils/clipboard.ts +56 -0
- package/src/utils/format.ts +15 -0
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ A lightweight terminal SQLite browser with an ncdu-style interface.
|
|
|
7
7
|
- **Minimal dependencies** - Uses Node.js 24+ built-in `node:sqlite` module
|
|
8
8
|
- **Full-screen TUI** - ncdu-inspired keyboard navigation
|
|
9
9
|
|
|
10
|
+
[](https://asciinema.org/a/861358)
|
|
11
|
+
|
|
10
12
|
## Installation
|
|
11
13
|
|
|
12
14
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dbn-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A lightweight terminal-based database browser",
|
|
5
5
|
"repository": "https://github.com/amio/dbn-cli",
|
|
6
6
|
"type": "module",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
"@types/node": "^24.10.9"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"string-width": "^
|
|
38
|
+
"string-width": "^8.2.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -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/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
|
@@ -236,11 +236,18 @@ export class Renderer {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
private renderRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
|
|
239
|
-
const allLines: string[] = [];
|
|
240
|
-
const { row, schema } = state;
|
|
241
239
|
const innerWidth = width - 2;
|
|
242
240
|
const box = new Box({ width, padding: 1, background: THEME.background });
|
|
243
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
|
+
|
|
244
251
|
// Calculate max label width for alignment
|
|
245
252
|
let maxLabelWidth = 0;
|
|
246
253
|
schema.forEach(col => {
|
|
@@ -278,6 +285,9 @@ export class Renderer {
|
|
|
278
285
|
}
|
|
279
286
|
});
|
|
280
287
|
|
|
288
|
+
state.cachedLines = allLines;
|
|
289
|
+
state.cachedWidth = width;
|
|
290
|
+
state.cachedRowIndex = state.rowIndex;
|
|
281
291
|
state.totalLines = allLines.length;
|
|
282
292
|
state.visibleHeight = height;
|
|
283
293
|
|
|
@@ -339,6 +349,7 @@ export class Renderer {
|
|
|
339
349
|
helpItems = [
|
|
340
350
|
{ key: 'j/k', label: 'switch' },
|
|
341
351
|
{ key: '↑/↓', label: 'scroll' },
|
|
352
|
+
{ key: 'c', label: 'copy' },
|
|
342
353
|
{ key: 'h', label: 'back' },
|
|
343
354
|
{ key: 'q', label: 'quit' }
|
|
344
355
|
];
|
|
@@ -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
|
+
}
|
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
|
|