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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbn-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "A lightweight terminal-based database browser",
5
5
  "repository": "https://github.com/amio/dbn-cli",
6
6
  "type": "module",
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.screen.on('resize', () => {
77
- this.render();
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
  }
@@ -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
  }
@@ -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
  },
@@ -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
  */
@@ -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
- // Clear and render
42
- this.screen.clear();
43
- this.screen.write(lines.join('\n'));
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
@@ -17,7 +17,7 @@ export const THEME = {
17
17
  text: '#FFFFFF',
18
18
  textDim: '#8E8E93',
19
19
  headerBg: '#242426',
20
- footerBg: '#1C1C1E',
20
+ footerBg: '',
21
21
  selectionBg: '#3A3A3C',
22
22
  success: '#34C759',
23
23
  warning: '#FF9500',
@@ -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
+ }
@@ -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