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 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
+ [![asciicast](https://asciinema.org/a/861358.svg)](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.5.3",
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": "^5.0.1"
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
  }
@@ -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', () => {
@@ -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
  */
@@ -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
+ }
@@ -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