dbn-cli 0.2.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 +41 -0
- package/bin/dbn.ts +9 -0
- package/package.json +40 -0
- package/src/adapter/base.ts +46 -0
- package/src/adapter/sqlite.ts +110 -0
- package/src/index.ts +250 -0
- package/src/types.ts +109 -0
- package/src/ui/navigator.ts +254 -0
- package/src/ui/renderer.ts +499 -0
- package/src/ui/screen.ts +101 -0
- package/src/ui/theme.ts +58 -0
- package/src/utils/format.ts +137 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { COLORS, BORDERS, UI } from './theme.ts';
|
|
2
|
+
import { formatNumber, truncate, pad, formatValue, getVisibleWidth } from '../utils/format.ts';
|
|
3
|
+
import type { Screen } from './screen.ts';
|
|
4
|
+
import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renderer for ncdu-style TUI
|
|
8
|
+
*/
|
|
9
|
+
export class Renderer {
|
|
10
|
+
private screen: Screen;
|
|
11
|
+
|
|
12
|
+
constructor(screen: Screen) {
|
|
13
|
+
this.screen = screen;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render the current state to screen
|
|
18
|
+
* @param state - Current navigation state
|
|
19
|
+
* @param dbPath - Database file path
|
|
20
|
+
*/
|
|
21
|
+
render(state: ViewState, dbPath: string): void {
|
|
22
|
+
const { width, height } = this.screen;
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
|
|
25
|
+
// Build all lines
|
|
26
|
+
lines.push(this.buildTitleBar(state, dbPath, width));
|
|
27
|
+
lines.push(this.buildSeparator(width));
|
|
28
|
+
|
|
29
|
+
const contentLines = this.buildContent(state, height - 3, width);
|
|
30
|
+
lines.push(...contentLines);
|
|
31
|
+
|
|
32
|
+
lines.push(this.buildHelpBar(state, width));
|
|
33
|
+
|
|
34
|
+
// Clear and render
|
|
35
|
+
this.screen.clear();
|
|
36
|
+
this.screen.write(lines.join('\n'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build title bar (top line)
|
|
41
|
+
*/
|
|
42
|
+
private buildTitleBar(state: ViewState, dbPath: string, width: number): string {
|
|
43
|
+
const fileName = dbPath.split('/').pop() || dbPath;
|
|
44
|
+
let title = ` ${fileName}`;
|
|
45
|
+
|
|
46
|
+
if (state.type === 'table-detail') {
|
|
47
|
+
title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName}`;
|
|
48
|
+
} else if (state.type === 'schema-view') {
|
|
49
|
+
title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName} ${COLORS.dim}> schema${COLORS.reset}`;
|
|
50
|
+
} else if (state.type === 'row-detail') {
|
|
51
|
+
title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName} ${COLORS.dim}> row ${state.rowIndex + 1}${COLORS.reset}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
title = `${COLORS.bold}${title}${COLORS.reset}`;
|
|
55
|
+
|
|
56
|
+
// Build right info
|
|
57
|
+
let rightInfo = '';
|
|
58
|
+
if (state.type === 'tables') {
|
|
59
|
+
const totalTables = state.tables.length;
|
|
60
|
+
const currentPos = state.cursor + 1;
|
|
61
|
+
rightInfo = `${COLORS.dim}${currentPos}/${totalTables} tables${COLORS.reset}`;
|
|
62
|
+
} else if (state.type === 'table-detail') {
|
|
63
|
+
const totalRows = formatNumber(state.totalRows);
|
|
64
|
+
const currentRow = state.dataOffset + state.dataCursor + 1;
|
|
65
|
+
rightInfo = `${COLORS.dim}row ${formatNumber(currentRow)}/${totalRows}${COLORS.reset}`;
|
|
66
|
+
} else if (state.type === 'schema-view') {
|
|
67
|
+
const totalCols = state.schema.length;
|
|
68
|
+
const currentPos = state.cursor + 1;
|
|
69
|
+
rightInfo = `${COLORS.dim}${currentPos}/${totalCols} columns${COLORS.reset}`;
|
|
70
|
+
} else if (state.type === 'row-detail') {
|
|
71
|
+
const colCount = state.schema.length;
|
|
72
|
+
rightInfo = `${COLORS.dim}${colCount} fields${COLORS.reset}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Calculate spacing using visible width (accounts for CJK double-width)
|
|
76
|
+
const titleWidth = getVisibleWidth(title);
|
|
77
|
+
const rightInfoWidth = getVisibleWidth(rightInfo);
|
|
78
|
+
const availableSpace = width - titleWidth - rightInfoWidth;
|
|
79
|
+
|
|
80
|
+
// Adjust width to prevent wrapping issues with ambiguous width characters
|
|
81
|
+
const safeWidth = width - 1;
|
|
82
|
+
|
|
83
|
+
if (availableSpace > 0) {
|
|
84
|
+
// Ensure we don't exceed safeWidth
|
|
85
|
+
let result = title + ' '.repeat(availableSpace) + rightInfo;
|
|
86
|
+
const resultWidth = getVisibleWidth(result);
|
|
87
|
+
if (resultWidth > safeWidth) {
|
|
88
|
+
return truncate(result, safeWidth);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
} else {
|
|
92
|
+
// Not enough space, truncate title
|
|
93
|
+
const maxTitleWidth = safeWidth - rightInfoWidth - 3; // Reserve space for "..."
|
|
94
|
+
if (maxTitleWidth > 10) {
|
|
95
|
+
const truncatedTitle = truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), maxTitleWidth);
|
|
96
|
+
const truncatedWidth = getVisibleWidth(truncatedTitle);
|
|
97
|
+
const padding = safeWidth - truncatedWidth - rightInfoWidth;
|
|
98
|
+
return `${COLORS.bold}${truncatedTitle}${COLORS.reset}` + ' '.repeat(Math.max(0, padding)) + rightInfo;
|
|
99
|
+
} else {
|
|
100
|
+
// Very narrow screen, just show title
|
|
101
|
+
return truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), safeWidth);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build separator line
|
|
108
|
+
*/
|
|
109
|
+
private buildSeparator(width: number): string {
|
|
110
|
+
return `${COLORS.dim}${BORDERS.horizontal.repeat(width)}${COLORS.reset}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build main content area
|
|
115
|
+
*/
|
|
116
|
+
private buildContent(state: ViewState, height: number, width: number): string[] {
|
|
117
|
+
if (state.type === 'tables') {
|
|
118
|
+
return this.buildTablesList(state, height, width);
|
|
119
|
+
} else if (state.type === 'table-detail') {
|
|
120
|
+
return this.buildTableDetail(state, height, width);
|
|
121
|
+
} else if (state.type === 'schema-view') {
|
|
122
|
+
return this.buildSchemaView(state, height, width);
|
|
123
|
+
} else if (state.type === 'row-detail') {
|
|
124
|
+
return this.buildRowDetail(state, height, width);
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build tables list view
|
|
131
|
+
*/
|
|
132
|
+
private buildTablesList(state: TablesViewState, height: number, width: number): string[] {
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
const { tables, cursor } = state;
|
|
135
|
+
|
|
136
|
+
if (tables.length === 0) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(pad('No tables found', width, 'center'));
|
|
139
|
+
while (lines.length < height) {
|
|
140
|
+
lines.push('');
|
|
141
|
+
}
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Calculate visible window
|
|
146
|
+
const halfHeight = Math.floor(height / 2);
|
|
147
|
+
let startIdx = Math.max(0, cursor - halfHeight);
|
|
148
|
+
let endIdx = Math.min(tables.length, startIdx + height);
|
|
149
|
+
|
|
150
|
+
// Adjust if at the end
|
|
151
|
+
if (endIdx - startIdx < height) {
|
|
152
|
+
startIdx = Math.max(0, endIdx - height);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
156
|
+
const table = tables[i];
|
|
157
|
+
const isSelected = i === cursor;
|
|
158
|
+
|
|
159
|
+
// Build line without color codes first
|
|
160
|
+
const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
|
|
161
|
+
const maxNameWidth = width - 20; // Reserve space for row count
|
|
162
|
+
const name = truncate(table.name, maxNameWidth - 5);
|
|
163
|
+
const namePadded = pad(name, maxNameWidth - 3);
|
|
164
|
+
const count = formatNumber(table.row_count) + ' rows';
|
|
165
|
+
const countPadded = pad(count, 15, 'right');
|
|
166
|
+
|
|
167
|
+
let content = `${cursorChar} ${namePadded} ${countPadded}`;
|
|
168
|
+
|
|
169
|
+
// Ensure exact width before adding color codes
|
|
170
|
+
const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
|
|
171
|
+
const contentWidth = getVisibleWidth(content);
|
|
172
|
+
if (contentWidth > safeWidth) {
|
|
173
|
+
content = truncate(content, safeWidth);
|
|
174
|
+
} else if (contentWidth < safeWidth) {
|
|
175
|
+
content += ' '.repeat(safeWidth - contentWidth);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Apply color codes to properly sized line
|
|
179
|
+
let line: string;
|
|
180
|
+
if (isSelected) {
|
|
181
|
+
line = COLORS.inverse + content + COLORS.reset;
|
|
182
|
+
} else {
|
|
183
|
+
line = content;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
lines.push(line);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fill remaining lines
|
|
190
|
+
while (lines.length < height) {
|
|
191
|
+
lines.push(' '.repeat(width));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build table detail view
|
|
199
|
+
*/
|
|
200
|
+
private buildTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
|
|
201
|
+
const lines: string[] = [];
|
|
202
|
+
const { data, totalRows, dataOffset, dataCursor } = state;
|
|
203
|
+
|
|
204
|
+
if (data.length === 0) {
|
|
205
|
+
lines.push(pad('No data', width, 'center'));
|
|
206
|
+
} else {
|
|
207
|
+
// Get column names
|
|
208
|
+
const columns = Object.keys(data[0]);
|
|
209
|
+
|
|
210
|
+
// Calculate optimal column widths based on content
|
|
211
|
+
const minColWidth = 8;
|
|
212
|
+
const maxColWidth = 50; // Maximum width for any single column
|
|
213
|
+
const maxVisibleCols = 8;
|
|
214
|
+
const visibleColumns = columns.slice(0, maxVisibleCols);
|
|
215
|
+
const availableWidth = width - 4; // Reserve space for padding and cursor
|
|
216
|
+
const spacingWidth = visibleColumns.length - 1; // Space between columns
|
|
217
|
+
const usableWidth = availableWidth - spacingWidth;
|
|
218
|
+
|
|
219
|
+
// Calculate ideal width for each column based on content
|
|
220
|
+
const idealWidths = visibleColumns.map(col => {
|
|
221
|
+
// Check column name length
|
|
222
|
+
let maxWidth = col.length;
|
|
223
|
+
|
|
224
|
+
// Check data values length (sample first few rows for performance)
|
|
225
|
+
const sampleSize = Math.min(data.length, 20);
|
|
226
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
227
|
+
const value = formatValue(data[i][col]);
|
|
228
|
+
maxWidth = Math.max(maxWidth, value.length);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Apply constraints: add 2 for padding, cap at maxColWidth
|
|
232
|
+
return Math.max(minColWidth, Math.min(maxWidth + 2, maxColWidth));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Calculate total ideal width
|
|
236
|
+
let totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0);
|
|
237
|
+
|
|
238
|
+
// Allocate widths
|
|
239
|
+
const colWidths: number[] = [];
|
|
240
|
+
|
|
241
|
+
if (totalIdealWidth <= usableWidth) {
|
|
242
|
+
// We have extra space - distribute it intelligently
|
|
243
|
+
const extraSpace = usableWidth - totalIdealWidth;
|
|
244
|
+
|
|
245
|
+
// Find columns that could use more space (those at maxColWidth)
|
|
246
|
+
const expandableIndices = idealWidths
|
|
247
|
+
.map((w, i) => ({ width: w, index: i }))
|
|
248
|
+
.filter(item => item.width === maxColWidth)
|
|
249
|
+
.map(item => item.index);
|
|
250
|
+
|
|
251
|
+
// Distribute extra space only to expandable columns
|
|
252
|
+
if (expandableIndices.length > 0) {
|
|
253
|
+
const extraPerCol = Math.floor(extraSpace / expandableIndices.length);
|
|
254
|
+
for (let i = 0; i < visibleColumns.length; i++) {
|
|
255
|
+
if (expandableIndices.includes(i)) {
|
|
256
|
+
colWidths[i] = idealWidths[i] + extraPerCol;
|
|
257
|
+
} else {
|
|
258
|
+
colWidths[i] = idealWidths[i];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Add remainder to last expandable column
|
|
262
|
+
const remainder = extraSpace - (extraPerCol * expandableIndices.length);
|
|
263
|
+
colWidths[expandableIndices[expandableIndices.length - 1]] += remainder;
|
|
264
|
+
} else {
|
|
265
|
+
// No expandable columns, distribute evenly to all
|
|
266
|
+
const extraPerCol = Math.floor(extraSpace / visibleColumns.length);
|
|
267
|
+
for (let i = 0; i < visibleColumns.length; i++) {
|
|
268
|
+
colWidths[i] = idealWidths[i] + extraPerCol;
|
|
269
|
+
}
|
|
270
|
+
const remainder = extraSpace - (extraPerCol * visibleColumns.length);
|
|
271
|
+
colWidths[colWidths.length - 1] += remainder;
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
// Need to scale down - use proportional scaling
|
|
275
|
+
const scale = usableWidth / totalIdealWidth;
|
|
276
|
+
for (let i = 0; i < visibleColumns.length; i++) {
|
|
277
|
+
colWidths[i] = Math.max(minColWidth, Math.floor(idealWidths[i] * scale));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Adjust to fill exact width
|
|
281
|
+
const totalWidth = colWidths.reduce((sum, w) => sum + w, 0);
|
|
282
|
+
const diff = usableWidth - totalWidth;
|
|
283
|
+
if (diff !== 0) {
|
|
284
|
+
colWidths[colWidths.length - 1] += diff;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Render table header
|
|
289
|
+
const headerCells = visibleColumns.map((col, idx) => {
|
|
290
|
+
const truncatedCol = truncate(col, colWidths[idx] - 1);
|
|
291
|
+
return pad(truncatedCol, colWidths[idx] - 1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
let headerLine = ` ${COLORS.dim} ${headerCells.join(' ')}${COLORS.reset}`;
|
|
295
|
+
const headerWidth = getVisibleWidth(headerLine);
|
|
296
|
+
|
|
297
|
+
if (headerWidth < width) {
|
|
298
|
+
headerLine += ' '.repeat(width - headerWidth);
|
|
299
|
+
} else if (headerWidth > width) {
|
|
300
|
+
// Truncate if somehow too wide
|
|
301
|
+
const plainHeader = ` ${headerCells.map(c => c.replace(/\x1b\[[0-9;]*m/g, '')).join(' ')}`;
|
|
302
|
+
headerLine = truncate(plainHeader, width);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
lines.push(headerLine);
|
|
306
|
+
|
|
307
|
+
// Update visible rows for navigator (subtract 1 for header)
|
|
308
|
+
state.visibleRows = height - 1;
|
|
309
|
+
|
|
310
|
+
// Render data rows
|
|
311
|
+
const maxRows = Math.min(data.length, height - 1);
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < maxRows; i++) {
|
|
314
|
+
const row = data[i];
|
|
315
|
+
const isSelected = i === dataCursor;
|
|
316
|
+
|
|
317
|
+
const cells = visibleColumns.map((col, idx) => {
|
|
318
|
+
const value = formatValue(row[col]);
|
|
319
|
+
return truncate(value, colWidths[idx] - 1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Build content without color codes first
|
|
323
|
+
const prefix = isSelected ? ` ${UI.cursor}` : ' ';
|
|
324
|
+
const contentWithoutPrefix = cells.map((cell, idx) => pad(cell, colWidths[idx] - 1)).join(' ');
|
|
325
|
+
|
|
326
|
+
// Ensure exact width before adding color codes (using visible width for CJK)
|
|
327
|
+
const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
|
|
328
|
+
let content = prefix + ' ' + contentWithoutPrefix;
|
|
329
|
+
const contentWidth = getVisibleWidth(content);
|
|
330
|
+
|
|
331
|
+
if (contentWidth > safeWidth) {
|
|
332
|
+
content = truncate(content, safeWidth);
|
|
333
|
+
} else if (contentWidth < safeWidth) {
|
|
334
|
+
content += ' '.repeat(safeWidth - contentWidth);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Apply color codes to properly sized line
|
|
338
|
+
let line: string;
|
|
339
|
+
if (isSelected) {
|
|
340
|
+
line = COLORS.inverse + content + COLORS.reset;
|
|
341
|
+
} else {
|
|
342
|
+
line = content;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
lines.push(line);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Fill remaining lines
|
|
350
|
+
while (lines.length < height) {
|
|
351
|
+
lines.push(' '.repeat(width));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Build schema view (full screen)
|
|
359
|
+
*/
|
|
360
|
+
private buildSchemaView(state: SchemaViewState, height: number, width: number): string[] {
|
|
361
|
+
const lines: string[] = [];
|
|
362
|
+
const { schema, cursor, scrollOffset } = state;
|
|
363
|
+
|
|
364
|
+
if (schema.length === 0) {
|
|
365
|
+
lines.push(pad('No schema information', width, 'center'));
|
|
366
|
+
while (lines.length < height) {
|
|
367
|
+
lines.push(' '.repeat(width));
|
|
368
|
+
}
|
|
369
|
+
return lines;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Calculate visible window
|
|
373
|
+
const contentHeight = height; // Use full height
|
|
374
|
+
const halfHeight = Math.floor(contentHeight / 2);
|
|
375
|
+
let startIdx = Math.max(0, cursor - halfHeight);
|
|
376
|
+
let endIdx = Math.min(schema.length, startIdx + contentHeight);
|
|
377
|
+
|
|
378
|
+
// Adjust if at the end
|
|
379
|
+
if (endIdx - startIdx < contentHeight) {
|
|
380
|
+
startIdx = Math.max(0, endIdx - contentHeight);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Calculate column widths
|
|
384
|
+
const nameWidth = Math.min(30, Math.max(...schema.map(col => col.name.length)) + 2);
|
|
385
|
+
const typeWidth = Math.min(20, Math.max(...schema.map(col => col.type.length)) + 2);
|
|
386
|
+
const attrWidth = width - nameWidth - typeWidth - 10;
|
|
387
|
+
|
|
388
|
+
// Render schema rows
|
|
389
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
390
|
+
const col = schema[i];
|
|
391
|
+
const isSelected = i === cursor;
|
|
392
|
+
|
|
393
|
+
const name = pad(truncate(col.name, nameWidth - 1), nameWidth);
|
|
394
|
+
const type = pad(truncate(col.type, typeWidth - 1), typeWidth);
|
|
395
|
+
|
|
396
|
+
// Build attributes string
|
|
397
|
+
const attrs: string[] = [];
|
|
398
|
+
if (col.pk) attrs.push('PK');
|
|
399
|
+
if (col.notnull) attrs.push('NOT NULL');
|
|
400
|
+
if (col.dflt_value !== null) attrs.push(`DEFAULT ${col.dflt_value}`);
|
|
401
|
+
const attrStr = attrs.length > 0 ? truncate(attrs.join(', '), attrWidth) : '';
|
|
402
|
+
|
|
403
|
+
// Build content without selection color first
|
|
404
|
+
const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
|
|
405
|
+
const content = `${cursorChar} ${name} ${COLORS.cyan}${type}${COLORS.reset} ${COLORS.dim}${attrStr}${COLORS.reset}`;
|
|
406
|
+
|
|
407
|
+
// Calculate visible width (accounting for CJK and ANSI codes)
|
|
408
|
+
const contentWidth = getVisibleWidth(content);
|
|
409
|
+
|
|
410
|
+
// Build line with exact width
|
|
411
|
+
const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
|
|
412
|
+
let line: string;
|
|
413
|
+
if (contentWidth < safeWidth) {
|
|
414
|
+
line = content + ' '.repeat(safeWidth - contentWidth);
|
|
415
|
+
} else if (contentWidth > safeWidth) {
|
|
416
|
+
// Need to truncate - build without colors first
|
|
417
|
+
const basicContent = `${cursorChar} ${name} ${type} ${attrStr}`;
|
|
418
|
+
const truncatedBasic = truncate(basicContent, safeWidth);
|
|
419
|
+
line = truncatedBasic;
|
|
420
|
+
} else {
|
|
421
|
+
line = content;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Apply inverse color for selected row (wraps entire line)
|
|
425
|
+
if (isSelected) {
|
|
426
|
+
line = COLORS.inverse + line + COLORS.reset;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
lines.push(line);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Fill remaining lines
|
|
433
|
+
while (lines.length < height) {
|
|
434
|
+
lines.push(' '.repeat(width));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return lines;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Build row detail view
|
|
442
|
+
*/
|
|
443
|
+
private buildRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
|
|
444
|
+
const lines: string[] = [];
|
|
445
|
+
const { row, schema, tableName, rowIndex } = state;
|
|
446
|
+
|
|
447
|
+
// Get max column name length for alignment
|
|
448
|
+
const maxColNameLength = Math.max(...schema.map(col => col.name.length), 10);
|
|
449
|
+
const rightMargin = 2; // Right side margin
|
|
450
|
+
const valueWidth = width - maxColNameLength - 5 - rightMargin;
|
|
451
|
+
|
|
452
|
+
// Display each field (one line per field)
|
|
453
|
+
let lineCount = 0;
|
|
454
|
+
for (const col of schema) {
|
|
455
|
+
if (lineCount >= height) break;
|
|
456
|
+
|
|
457
|
+
const colName = `${COLORS.cyan}${pad(col.name, maxColNameLength)}${COLORS.reset}`;
|
|
458
|
+
const value = formatValue(row[col.name]);
|
|
459
|
+
|
|
460
|
+
// Truncate value to fit in single line
|
|
461
|
+
const truncatedValue = truncate(value, valueWidth);
|
|
462
|
+
|
|
463
|
+
let line = ` ${colName} ${COLORS.dim}:${COLORS.reset} ${truncatedValue}`;
|
|
464
|
+
const lineWidth = getVisibleWidth(line);
|
|
465
|
+
if (lineWidth < width - rightMargin) {
|
|
466
|
+
line += ' '.repeat(width - rightMargin - lineWidth);
|
|
467
|
+
}
|
|
468
|
+
lines.push(line);
|
|
469
|
+
lineCount++;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Fill remaining lines
|
|
473
|
+
while (lines.length < height) {
|
|
474
|
+
lines.push(' '.repeat(width));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return lines;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Build help bar (bottom line)
|
|
482
|
+
*/
|
|
483
|
+
private buildHelpBar(state: ViewState, width: number): string {
|
|
484
|
+
let help = '';
|
|
485
|
+
|
|
486
|
+
if (state.type === 'tables') {
|
|
487
|
+
help = ' [j/k] select [Enter/l] open [g/G] top/bottom [q] quit';
|
|
488
|
+
} else if (state.type === 'table-detail') {
|
|
489
|
+
help = ' [j/k] scroll [Enter/l] view row [s] toggle schema [h/Esc] back [q] quit';
|
|
490
|
+
} else if (state.type === 'schema-view') {
|
|
491
|
+
help = ' [j/k] scroll [g/G] top/bottom [s/h/Esc] back [q] quit';
|
|
492
|
+
} else if (state.type === 'row-detail') {
|
|
493
|
+
help = ' [h/Esc] back [q] quit';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const paddedHelp = pad(help, width);
|
|
497
|
+
return `${COLORS.dim}${paddedHelp}${COLORS.reset}`;
|
|
498
|
+
}
|
|
499
|
+
}
|
package/src/ui/screen.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { stdin, stdout } from 'node:process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import type { ScreenDimensions } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Screen manager for full-screen TUI applications
|
|
7
|
+
* Handles alternate screen buffer and terminal state
|
|
8
|
+
*/
|
|
9
|
+
export class Screen extends EventEmitter {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
private isActive: boolean = false;
|
|
13
|
+
private resizeHandler?: () => void;
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
super();
|
|
17
|
+
this.width = stdout.columns || 80;
|
|
18
|
+
this.height = stdout.rows || 24;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Enter alternate screen buffer and set up terminal
|
|
23
|
+
*/
|
|
24
|
+
enter(): void {
|
|
25
|
+
if (this.isActive) return;
|
|
26
|
+
|
|
27
|
+
// Enter alternate screen buffer
|
|
28
|
+
stdout.write('\x1b[?1049h');
|
|
29
|
+
|
|
30
|
+
// Hide cursor
|
|
31
|
+
stdout.write('\x1b[?25l');
|
|
32
|
+
|
|
33
|
+
// Clear screen
|
|
34
|
+
stdout.write('\x1b[2J\x1b[H');
|
|
35
|
+
|
|
36
|
+
// Listen for terminal resize
|
|
37
|
+
this.resizeHandler = () => {
|
|
38
|
+
this.width = stdout.columns || 80;
|
|
39
|
+
this.height = stdout.rows || 24;
|
|
40
|
+
this.emit('resize', { width: this.width, height: this.height } as ScreenDimensions);
|
|
41
|
+
};
|
|
42
|
+
process.on('SIGWINCH', this.resizeHandler);
|
|
43
|
+
|
|
44
|
+
this.isActive = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Exit alternate screen buffer and restore terminal
|
|
49
|
+
*/
|
|
50
|
+
exit(): void {
|
|
51
|
+
if (!this.isActive) return;
|
|
52
|
+
|
|
53
|
+
// Show cursor
|
|
54
|
+
stdout.write('\x1b[?25h');
|
|
55
|
+
|
|
56
|
+
// Exit alternate screen buffer
|
|
57
|
+
stdout.write('\x1b[?1049l');
|
|
58
|
+
|
|
59
|
+
// Remove resize listener
|
|
60
|
+
if (this.resizeHandler) {
|
|
61
|
+
process.off('SIGWINCH', this.resizeHandler);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.isActive = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Clear the screen
|
|
69
|
+
*/
|
|
70
|
+
clear(): void {
|
|
71
|
+
stdout.write('\x1b[2J\x1b[H');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Move cursor to specific position
|
|
76
|
+
* @param row - Row (1-indexed)
|
|
77
|
+
* @param col - Column (1-indexed)
|
|
78
|
+
*/
|
|
79
|
+
moveCursor(row: number, col: number): void {
|
|
80
|
+
stdout.write(`\x1b[${row};${col}H`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Write text to the screen
|
|
85
|
+
* @param text - Text to write
|
|
86
|
+
*/
|
|
87
|
+
write(text: string): void {
|
|
88
|
+
stdout.write(text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write text at specific position
|
|
93
|
+
* @param row - Row (1-indexed)
|
|
94
|
+
* @param col - Column (1-indexed)
|
|
95
|
+
* @param text - Text to write
|
|
96
|
+
*/
|
|
97
|
+
writeAt(row: number, col: number, text: string): void {
|
|
98
|
+
this.moveCursor(row, col);
|
|
99
|
+
this.write(text);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI color codes and styling
|
|
3
|
+
*/
|
|
4
|
+
export const COLORS = {
|
|
5
|
+
reset: '\x1b[0m',
|
|
6
|
+
bold: '\x1b[1m',
|
|
7
|
+
dim: '\x1b[2m',
|
|
8
|
+
italic: '\x1b[3m',
|
|
9
|
+
underline: '\x1b[4m',
|
|
10
|
+
inverse: '\x1b[7m',
|
|
11
|
+
|
|
12
|
+
// Foreground colors
|
|
13
|
+
black: '\x1b[30m',
|
|
14
|
+
red: '\x1b[31m',
|
|
15
|
+
green: '\x1b[32m',
|
|
16
|
+
yellow: '\x1b[33m',
|
|
17
|
+
blue: '\x1b[34m',
|
|
18
|
+
magenta: '\x1b[35m',
|
|
19
|
+
cyan: '\x1b[36m',
|
|
20
|
+
white: '\x1b[37m',
|
|
21
|
+
gray: '\x1b[90m',
|
|
22
|
+
|
|
23
|
+
// Background colors
|
|
24
|
+
bgBlack: '\x1b[40m',
|
|
25
|
+
bgRed: '\x1b[41m',
|
|
26
|
+
bgGreen: '\x1b[42m',
|
|
27
|
+
bgYellow: '\x1b[43m',
|
|
28
|
+
bgBlue: '\x1b[44m',
|
|
29
|
+
bgMagenta: '\x1b[45m',
|
|
30
|
+
bgCyan: '\x1b[46m',
|
|
31
|
+
bgWhite: '\x1b[47m',
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Unicode box drawing characters
|
|
36
|
+
*/
|
|
37
|
+
export const BORDERS = {
|
|
38
|
+
horizontal: '─',
|
|
39
|
+
vertical: '│',
|
|
40
|
+
topLeft: '┌',
|
|
41
|
+
topRight: '┐',
|
|
42
|
+
bottomLeft: '└',
|
|
43
|
+
bottomRight: '┘',
|
|
44
|
+
leftJoin: '├',
|
|
45
|
+
rightJoin: '┤',
|
|
46
|
+
topJoin: '┬',
|
|
47
|
+
bottomJoin: '┴',
|
|
48
|
+
cross: '┼',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Common UI elements
|
|
53
|
+
*/
|
|
54
|
+
export const UI = {
|
|
55
|
+
cursor: '>',
|
|
56
|
+
empty: ' ',
|
|
57
|
+
ellipsis: '...',
|
|
58
|
+
} as const;
|