dbn-cli 0.4.0 → 0.5.2
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 +5 -5
- package/src/adapter/adapter.test.ts +207 -0
- package/src/index.ts +20 -2
- package/src/repro_bug.test.ts +101 -0
- package/src/types.ts +8 -0
- package/src/ui/grit/README.md +58 -0
- package/src/ui/grit/index.test.ts +67 -0
- package/src/ui/grit/index.ts +101 -0
- package/src/ui/grit/types.ts +16 -0
- package/src/ui/grit/utils.ts +35 -0
- package/src/ui/navigator.test.ts +434 -0
- package/src/ui/navigator.ts +199 -36
- package/src/ui/navigator_sync.test.ts +95 -0
- package/src/ui/renderer.ts +242 -490
- package/src/ui/screen.ts +6 -6
- package/src/ui/theme.test.ts +30 -0
- package/src/ui/theme.ts +20 -46
- package/src/utils/format.test.ts +209 -0
- package/src/utils/format.ts +94 -21
package/src/ui/renderer.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { formatNumber, truncate, pad, formatValue, getVisibleWidth } from '../utils/format.ts';
|
|
1
|
+
import { THEME, ANSI } from './theme.ts';
|
|
2
|
+
import { formatNumber, truncate, pad, formatValue, getVisibleWidth, wrapText } from '../utils/format.ts';
|
|
3
|
+
import { Box, Transition, Grid, type ColumnConfig } from './grit/index.ts';
|
|
3
4
|
import type { Screen } from './screen.ts';
|
|
4
5
|
import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState, HealthViewState } from '../types.ts';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* Renderer
|
|
8
|
+
* Modern Renderer (Manual ANSI Implementation)
|
|
9
|
+
* Emulates OpenTUI/OpenCode design with color blocks and no lines.
|
|
8
10
|
*/
|
|
9
11
|
export class Renderer {
|
|
10
12
|
private screen: Screen;
|
|
@@ -15,20 +17,25 @@ export class Renderer {
|
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Render the current state to screen
|
|
18
|
-
* @param state - Current navigation state
|
|
19
|
-
* @param dbPath - Database file path
|
|
20
20
|
*/
|
|
21
21
|
render(state: ViewState, dbPath: string): void {
|
|
22
22
|
const { width, height } = this.screen;
|
|
23
23
|
const lines: string[] = [];
|
|
24
24
|
|
|
25
|
-
//
|
|
25
|
+
// Header transition color can match table header if in table-detail
|
|
26
|
+
const contentTopBg = state.type === 'table-detail' ? THEME.surface : THEME.background;
|
|
27
|
+
|
|
28
|
+
// 1. Title Bar (Header Block)
|
|
26
29
|
lines.push(this.buildTitleBar(state, dbPath, width));
|
|
27
|
-
lines.push(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
lines.push(Transition.draw(width, THEME.headerBg, contentTopBg));
|
|
31
|
+
|
|
32
|
+
// 2. Main Content area
|
|
33
|
+
const contentHeight = height - 4; // 1 header, 1 footer, 2 transitions
|
|
34
|
+
const contentLines = this.buildContent(state, contentHeight, width);
|
|
30
35
|
lines.push(...contentLines);
|
|
31
|
-
|
|
36
|
+
|
|
37
|
+
// 3. Help/Status Bar (Footer Block)
|
|
38
|
+
lines.push(Transition.draw(width, THEME.background, THEME.footerBg));
|
|
32
39
|
lines.push(this.buildHelpBar(state, width));
|
|
33
40
|
|
|
34
41
|
// Clear and render
|
|
@@ -36,550 +43,295 @@ export class Renderer {
|
|
|
36
43
|
this.screen.write(lines.join('\n'));
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
/**
|
|
40
|
-
* Build title bar (top line)
|
|
41
|
-
*/
|
|
42
46
|
private buildTitleBar(state: ViewState, dbPath: string, width: number): string {
|
|
43
47
|
const fileName = dbPath.split('/').pop() || dbPath;
|
|
44
|
-
let
|
|
48
|
+
let breadcrumb = ` ${fileName}`;
|
|
45
49
|
|
|
46
|
-
if (state.type === 'table-detail') {
|
|
47
|
-
|
|
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
|
-
} else if (state.type === 'health') {
|
|
53
|
-
title += ` ${COLORS.dim}>${COLORS.reset} health`;
|
|
50
|
+
if (state.type === 'table-detail' || state.type === 'schema-view' || state.type === 'row-detail') {
|
|
51
|
+
breadcrumb += ` > ${state.tableName}`;
|
|
54
52
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
if (state.type === 'schema-view') breadcrumb += ` > schema`;
|
|
54
|
+
if (state.type === 'row-detail') breadcrumb += ` > row ${state.rowIndex + 1}`;
|
|
55
|
+
if (state.type === 'health') breadcrumb += ` > health`;
|
|
56
|
+
|
|
57
|
+
const leftPart = `${ANSI.bold}${ANSI.fg(THEME.primary)}${breadcrumb}${ANSI.reset}`;
|
|
58
|
+
|
|
59
|
+
let rightPart = '';
|
|
60
60
|
if (state.type === 'tables') {
|
|
61
|
-
|
|
62
|
-
const currentPos = state.cursor + 1;
|
|
63
|
-
rightInfo = `${COLORS.dim}${currentPos}/${totalTables} tables${COLORS.reset}`;
|
|
61
|
+
rightPart = `${state.cursor + 1}/${state.tables.length} tables`;
|
|
64
62
|
} else if (state.type === 'table-detail') {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
rightInfo = `${COLORS.dim}row ${formatNumber(currentRow)}/${totalRows}${COLORS.reset}`;
|
|
63
|
+
const current = state.dataOffset + state.dataCursor + 1;
|
|
64
|
+
rightPart = `row ${formatNumber(current)}/${formatNumber(state.totalRows)}`;
|
|
68
65
|
} else if (state.type === 'schema-view') {
|
|
69
|
-
|
|
70
|
-
const currentPos = state.cursor + 1;
|
|
71
|
-
rightInfo = `${COLORS.dim}${currentPos}/${totalCols} columns${COLORS.reset}`;
|
|
66
|
+
rightPart = `${state.cursor + 1}/${state.schema.length} columns`;
|
|
72
67
|
} else if (state.type === 'row-detail') {
|
|
73
|
-
|
|
74
|
-
rightInfo = `${COLORS.dim}${colCount} fields${COLORS.reset}`;
|
|
75
|
-
} else if (state.type === 'health') {
|
|
76
|
-
rightInfo = `${COLORS.dim}overview${COLORS.reset}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Calculate spacing using visible width (accounts for CJK double-width)
|
|
80
|
-
const titleWidth = getVisibleWidth(title);
|
|
81
|
-
const rightInfoWidth = getVisibleWidth(rightInfo);
|
|
82
|
-
const availableSpace = width - titleWidth - rightInfoWidth;
|
|
83
|
-
|
|
84
|
-
// Adjust width to prevent wrapping issues with ambiguous width characters
|
|
85
|
-
const safeWidth = width - 1;
|
|
86
|
-
|
|
87
|
-
if (availableSpace > 0) {
|
|
88
|
-
// Ensure we don't exceed safeWidth
|
|
89
|
-
let result = title + ' '.repeat(availableSpace) + rightInfo;
|
|
90
|
-
const resultWidth = getVisibleWidth(result);
|
|
91
|
-
if (resultWidth > safeWidth) {
|
|
92
|
-
return truncate(result, safeWidth);
|
|
93
|
-
}
|
|
94
|
-
return result;
|
|
95
|
-
} else {
|
|
96
|
-
// Not enough space, truncate title
|
|
97
|
-
const maxTitleWidth = safeWidth - rightInfoWidth - 3; // Reserve space for "..."
|
|
98
|
-
if (maxTitleWidth > 10) {
|
|
99
|
-
const truncatedTitle = truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), maxTitleWidth);
|
|
100
|
-
const truncatedWidth = getVisibleWidth(truncatedTitle);
|
|
101
|
-
const padding = safeWidth - truncatedWidth - rightInfoWidth;
|
|
102
|
-
return `${COLORS.bold}${truncatedTitle}${COLORS.reset}` + ' '.repeat(Math.max(0, padding)) + rightInfo;
|
|
103
|
-
} else {
|
|
104
|
-
// Very narrow screen, just show title
|
|
105
|
-
return truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), safeWidth);
|
|
106
|
-
}
|
|
68
|
+
rightPart = `${state.schema.length} fields`;
|
|
107
69
|
}
|
|
108
|
-
}
|
|
109
70
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
71
|
+
const rightPartStyled = `${ANSI.fg(THEME.textDim)}${rightPart}${ANSI.reset}`;
|
|
72
|
+
|
|
73
|
+
const padding = ' '.repeat(Math.max(0, width - 2 - getVisibleWidth(leftPart) - getVisibleWidth(rightPartStyled)));
|
|
74
|
+
const rowContent = `${leftPart}${padding}${rightPartStyled}`;
|
|
75
|
+
|
|
76
|
+
const box = new Box({ width, padding: 1, background: THEME.headerBg });
|
|
77
|
+
return box.render(rowContent);
|
|
115
78
|
}
|
|
116
79
|
|
|
117
|
-
/**
|
|
118
|
-
* Build main content area
|
|
119
|
-
*/
|
|
120
80
|
private buildContent(state: ViewState, height: number, width: number): string[] {
|
|
81
|
+
let content: string[] = [];
|
|
82
|
+
const bgBox = new Box({ width, background: THEME.background, padding: 1 });
|
|
83
|
+
|
|
121
84
|
if (state.type === 'tables') {
|
|
122
|
-
|
|
85
|
+
content = this.renderTables(state, height, width);
|
|
123
86
|
} else if (state.type === 'table-detail') {
|
|
124
|
-
|
|
87
|
+
content = this.renderTableDetail(state, height, width);
|
|
125
88
|
} else if (state.type === 'schema-view') {
|
|
126
|
-
|
|
89
|
+
content = this.renderSchema(state, height, width);
|
|
127
90
|
} else if (state.type === 'row-detail') {
|
|
128
|
-
|
|
91
|
+
content = this.renderRowDetail(state, height, width);
|
|
129
92
|
} else if (state.type === 'health') {
|
|
130
|
-
|
|
93
|
+
content = this.renderHealth(state, height, width);
|
|
131
94
|
}
|
|
132
|
-
|
|
95
|
+
|
|
96
|
+
// Fill remaining lines with background
|
|
97
|
+
while (content.length < height) {
|
|
98
|
+
content.push(bgBox.render(''));
|
|
99
|
+
}
|
|
100
|
+
return content;
|
|
133
101
|
}
|
|
134
102
|
|
|
135
|
-
|
|
136
|
-
* Build tables list view
|
|
137
|
-
*/
|
|
138
|
-
private buildTablesList(state: TablesViewState, height: number, width: number): string[] {
|
|
103
|
+
private renderTables(state: TablesViewState, height: number, width: number): string[] {
|
|
139
104
|
const lines: string[] = [];
|
|
140
105
|
const { tables, cursor } = state;
|
|
106
|
+
const box = new Box({ width, padding: 1 });
|
|
141
107
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
lines.push('');
|
|
147
|
-
}
|
|
148
|
-
return lines;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Calculate visible window
|
|
152
|
-
const halfHeight = Math.floor(height / 2);
|
|
153
|
-
let startIdx = Math.max(0, cursor - halfHeight);
|
|
154
|
-
let endIdx = Math.min(tables.length, startIdx + height);
|
|
155
|
-
|
|
156
|
-
// Adjust if at the end
|
|
157
|
-
if (endIdx - startIdx < height) {
|
|
158
|
-
startIdx = Math.max(0, endIdx - height);
|
|
159
|
-
}
|
|
108
|
+
const half = Math.floor(height / 2);
|
|
109
|
+
let start = Math.max(0, cursor - half);
|
|
110
|
+
let end = Math.min(tables.length, start + height);
|
|
111
|
+
if (end - start < height) start = Math.max(0, end - height);
|
|
160
112
|
|
|
161
|
-
for (let i =
|
|
162
|
-
const table = tables[i];
|
|
113
|
+
for (let i = start; i < end; i++) {
|
|
163
114
|
const isSelected = i === cursor;
|
|
115
|
+
const table = tables[i];
|
|
164
116
|
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
const maxNameWidth = width - 20; // Reserve space for row count
|
|
168
|
-
const name = truncate(table.name, maxNameWidth - 5);
|
|
169
|
-
const namePadded = pad(name, maxNameWidth - 3);
|
|
170
|
-
const count = formatNumber(table.row_count) + ' rows';
|
|
171
|
-
const countPadded = pad(count, 15, 'right');
|
|
172
|
-
|
|
173
|
-
let content = `${cursorChar} ${namePadded} ${countPadded}`;
|
|
174
|
-
|
|
175
|
-
// Ensure exact width before adding color codes
|
|
176
|
-
const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
|
|
177
|
-
const contentWidth = getVisibleWidth(content);
|
|
178
|
-
if (contentWidth > safeWidth) {
|
|
179
|
-
content = truncate(content, safeWidth);
|
|
180
|
-
} else if (contentWidth < safeWidth) {
|
|
181
|
-
content += ' '.repeat(safeWidth - contentWidth);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Apply color codes to properly sized line
|
|
185
|
-
let line: string;
|
|
186
|
-
if (isSelected) {
|
|
187
|
-
line = COLORS.inverse + content + COLORS.reset;
|
|
188
|
-
} else {
|
|
189
|
-
line = content;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
lines.push(line);
|
|
193
|
-
}
|
|
117
|
+
const name = isSelected ? `${ANSI.bold}${table.name}` : `${table.name}`;
|
|
118
|
+
const count = `${formatNumber(table.row_count)} rows`;
|
|
194
119
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
120
|
+
const bg = isSelected ? THEME.selectionBg : THEME.background;
|
|
121
|
+
const fg = isSelected ? THEME.primary : THEME.text;
|
|
122
|
+
|
|
123
|
+
const leftPart = `${ANSI.fg(fg)}${name}${ANSI.reset}`;
|
|
124
|
+
const rightPart = `${ANSI.fg(isSelected ? fg : THEME.textDim)}${count}${ANSI.reset}`;
|
|
199
125
|
|
|
126
|
+
const padding = ' '.repeat(Math.max(0, width - 2 - getVisibleWidth(leftPart) - getVisibleWidth(rightPart)));
|
|
127
|
+
const rowContent = `${leftPart}${padding}${rightPart}`;
|
|
128
|
+
lines.push(box.render(rowContent, { background: bg }));
|
|
129
|
+
}
|
|
200
130
|
return lines;
|
|
201
131
|
}
|
|
202
132
|
|
|
203
|
-
|
|
204
|
-
* Build table detail view
|
|
205
|
-
*/
|
|
206
|
-
private buildTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
|
|
133
|
+
private renderTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
|
|
207
134
|
const lines: string[] = [];
|
|
208
|
-
const { data,
|
|
135
|
+
const { data, dataOffset, dataCursor, bufferOffset } = state;
|
|
136
|
+
const box = new Box({ width, padding: 1 });
|
|
209
137
|
|
|
210
|
-
if (data.length === 0) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
138
|
+
if (data.length === 0) return [box.render('No data', { align: 'center' })];
|
|
139
|
+
|
|
140
|
+
const columns = Object.keys(data[0]).slice(0, 8);
|
|
141
|
+
|
|
142
|
+
// Calculate or use cached column widths
|
|
143
|
+
if (!state.cachedColWidths || state.cachedScreenWidth !== width) {
|
|
144
|
+
const numCols = columns.length;
|
|
217
145
|
const minColWidth = 8;
|
|
218
|
-
const maxColWidth = 50; // Maximum width for any single column
|
|
219
|
-
const maxVisibleCols = 8;
|
|
220
|
-
const visibleColumns = columns.slice(0, maxVisibleCols);
|
|
221
|
-
const availableWidth = width - 4; // Reserve space for padding and cursor
|
|
222
|
-
const spacingWidth = visibleColumns.length - 1; // Space between columns
|
|
223
|
-
const usableWidth = availableWidth - spacingWidth;
|
|
224
|
-
|
|
225
|
-
// Calculate ideal width for each column based on content
|
|
226
|
-
const idealWidths = visibleColumns.map(col => {
|
|
227
|
-
// Check column name length
|
|
228
|
-
let maxWidth = col.length;
|
|
229
|
-
|
|
230
|
-
// Check data values length (sample first few rows for performance)
|
|
231
|
-
const sampleSize = Math.min(data.length, 20);
|
|
232
|
-
for (let i = 0; i < sampleSize; i++) {
|
|
233
|
-
const value = formatValue(data[i][col]);
|
|
234
|
-
maxWidth = Math.max(maxWidth, value.length);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Apply constraints: add 2 for padding, cap at maxColWidth
|
|
238
|
-
return Math.max(minColWidth, Math.min(maxWidth + 2, maxColWidth));
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// Calculate total ideal width
|
|
242
|
-
let totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0);
|
|
243
|
-
|
|
244
|
-
// Allocate widths
|
|
245
|
-
const colWidths: number[] = [];
|
|
246
|
-
|
|
247
|
-
if (totalIdealWidth <= usableWidth) {
|
|
248
|
-
// We have extra space - distribute it intelligently
|
|
249
|
-
const extraSpace = usableWidth - totalIdealWidth;
|
|
250
|
-
|
|
251
|
-
// Find columns that could use more space (those at maxColWidth)
|
|
252
|
-
const expandableIndices = idealWidths
|
|
253
|
-
.map((w, i) => ({ width: w, index: i }))
|
|
254
|
-
.filter(item => item.width === maxColWidth)
|
|
255
|
-
.map(item => item.index);
|
|
256
|
-
|
|
257
|
-
// Distribute extra space only to expandable columns
|
|
258
|
-
if (expandableIndices.length > 0) {
|
|
259
|
-
const extraPerCol = Math.floor(extraSpace / expandableIndices.length);
|
|
260
|
-
for (let i = 0; i < visibleColumns.length; i++) {
|
|
261
|
-
if (expandableIndices.includes(i)) {
|
|
262
|
-
colWidths[i] = idealWidths[i] + extraPerCol;
|
|
263
|
-
} else {
|
|
264
|
-
colWidths[i] = idealWidths[i];
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
// Add remainder to last expandable column
|
|
268
|
-
const remainder = extraSpace - (extraPerCol * expandableIndices.length);
|
|
269
|
-
colWidths[expandableIndices[expandableIndices.length - 1]] += remainder;
|
|
270
|
-
} else {
|
|
271
|
-
// No expandable columns, distribute evenly to all
|
|
272
|
-
const extraPerCol = Math.floor(extraSpace / visibleColumns.length);
|
|
273
|
-
for (let i = 0; i < visibleColumns.length; i++) {
|
|
274
|
-
colWidths[i] = idealWidths[i] + extraPerCol;
|
|
275
|
-
}
|
|
276
|
-
const remainder = extraSpace - (extraPerCol * visibleColumns.length);
|
|
277
|
-
colWidths[colWidths.length - 1] += remainder;
|
|
278
|
-
}
|
|
279
|
-
} else {
|
|
280
|
-
// Need to scale down - use proportional scaling
|
|
281
|
-
const scale = usableWidth / totalIdealWidth;
|
|
282
|
-
for (let i = 0; i < visibleColumns.length; i++) {
|
|
283
|
-
colWidths[i] = Math.max(minColWidth, Math.floor(idealWidths[i] * scale));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Adjust to fill exact width
|
|
287
|
-
const totalWidth = colWidths.reduce((sum, w) => sum + w, 0);
|
|
288
|
-
const diff = usableWidth - totalWidth;
|
|
289
|
-
if (diff !== 0) {
|
|
290
|
-
colWidths[colWidths.length - 1] += diff;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Render table header
|
|
295
|
-
const headerCells = visibleColumns.map((col, idx) => {
|
|
296
|
-
const truncatedCol = truncate(col, colWidths[idx] - 1);
|
|
297
|
-
return pad(truncatedCol, colWidths[idx] - 1);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
let headerLine = ` ${COLORS.dim} ${headerCells.join(' ')}${COLORS.reset}`;
|
|
301
|
-
const headerWidth = getVisibleWidth(headerLine);
|
|
302
|
-
|
|
303
|
-
if (headerWidth < width) {
|
|
304
|
-
headerLine += ' '.repeat(width - headerWidth);
|
|
305
|
-
} else if (headerWidth > width) {
|
|
306
|
-
// Truncate if somehow too wide
|
|
307
|
-
const plainHeader = ` ${headerCells.map(c => c.replace(/\x1b\[[0-9;]*m/g, '')).join(' ')}`;
|
|
308
|
-
headerLine = truncate(plainHeader, width);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
lines.push(headerLine);
|
|
312
|
-
|
|
313
|
-
// Update visible rows for navigator (subtract 1 for header)
|
|
314
|
-
state.visibleRows = height - 1;
|
|
315
|
-
|
|
316
|
-
// Render data rows
|
|
317
|
-
const maxRows = Math.min(data.length, height - 1);
|
|
318
|
-
|
|
319
|
-
for (let i = 0; i < maxRows; i++) {
|
|
320
|
-
const row = data[i];
|
|
321
|
-
const isSelected = i === dataCursor;
|
|
322
|
-
|
|
323
|
-
const cells = visibleColumns.map((col, idx) => {
|
|
324
|
-
const value = formatValue(row[col]);
|
|
325
|
-
return truncate(value, colWidths[idx] - 1);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
// Build content without color codes first
|
|
329
|
-
const prefix = isSelected ? ` ${UI.cursor}` : ' ';
|
|
330
|
-
const contentWithoutPrefix = cells.map((cell, idx) => pad(cell, colWidths[idx] - 1)).join(' ');
|
|
331
|
-
|
|
332
|
-
// Ensure exact width before adding color codes (using visible width for CJK)
|
|
333
|
-
const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
|
|
334
|
-
let content = prefix + ' ' + contentWithoutPrefix;
|
|
335
|
-
const contentWidth = getVisibleWidth(content);
|
|
336
|
-
|
|
337
|
-
if (contentWidth > safeWidth) {
|
|
338
|
-
content = truncate(content, safeWidth);
|
|
339
|
-
} else if (contentWidth < safeWidth) {
|
|
340
|
-
content += ' '.repeat(safeWidth - contentWidth);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Apply color codes to properly sized line
|
|
344
|
-
let line: string;
|
|
345
|
-
if (isSelected) {
|
|
346
|
-
line = COLORS.inverse + content + COLORS.reset;
|
|
347
|
-
} else {
|
|
348
|
-
line = content;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
lines.push(line);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
146
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
147
|
+
const configs: ColumnConfig[] = columns.map((_, i) => ({
|
|
148
|
+
weight: (state.columnWeights && state.columnWeights[i]) || 1,
|
|
149
|
+
minWidth: minColWidth
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
state.cachedColWidths = Grid.calculateWidths(width - 2, configs);
|
|
153
|
+
state.cachedScreenWidth = width;
|
|
358
154
|
}
|
|
359
155
|
|
|
156
|
+
const colWidths = state.cachedColWidths!;
|
|
157
|
+
|
|
158
|
+
// Table Header Block
|
|
159
|
+
let headerContent = `${ANSI.fg(THEME.textDim)}${ANSI.bold}`;
|
|
160
|
+
columns.forEach((col, i) => {
|
|
161
|
+
const w = colWidths[i];
|
|
162
|
+
headerContent += pad(col, w - 1).slice(0, w - 1) + ' ';
|
|
163
|
+
});
|
|
164
|
+
lines.push(box.render(headerContent, { background: THEME.surface }));
|
|
165
|
+
lines.push(Transition.draw(width, THEME.surface, THEME.background));
|
|
166
|
+
|
|
167
|
+
// Data Rows
|
|
168
|
+
const relativeOffset = dataOffset - bufferOffset;
|
|
169
|
+
const displayData = data.slice(relativeOffset, relativeOffset + height - 2);
|
|
170
|
+
state.visibleRows = height - 2;
|
|
171
|
+
|
|
172
|
+
displayData.forEach((row, idx) => {
|
|
173
|
+
const isSelected = idx === dataCursor;
|
|
174
|
+
const rowBg = isSelected ? THEME.selectionBg : THEME.background;
|
|
175
|
+
const rowFg = isSelected ? THEME.primary : THEME.text;
|
|
176
|
+
|
|
177
|
+
let rowContent = `${ANSI.fg(rowFg)}`;
|
|
178
|
+
columns.forEach((col, i) => {
|
|
179
|
+
const w = colWidths[i];
|
|
180
|
+
const val = formatValue(row[col], w - 1);
|
|
181
|
+
rowContent += pad(val, w - 1).slice(0, w - 1) + ' ';
|
|
182
|
+
});
|
|
183
|
+
lines.push(box.render(rowContent, { background: rowBg }));
|
|
184
|
+
});
|
|
185
|
+
|
|
360
186
|
return lines;
|
|
361
187
|
}
|
|
362
188
|
|
|
363
|
-
|
|
364
|
-
* Build schema view (full screen)
|
|
365
|
-
*/
|
|
366
|
-
private buildSchemaView(state: SchemaViewState, height: number, width: number): string[] {
|
|
189
|
+
private renderSchema(state: SchemaViewState, height: number, width: number): string[] {
|
|
367
190
|
const lines: string[] = [];
|
|
368
|
-
const { schema, cursor
|
|
369
|
-
|
|
370
|
-
if (schema.length === 0) {
|
|
371
|
-
lines.push(pad('No schema information', width, 'center'));
|
|
372
|
-
while (lines.length < height) {
|
|
373
|
-
lines.push(' '.repeat(width));
|
|
374
|
-
}
|
|
375
|
-
return lines;
|
|
376
|
-
}
|
|
191
|
+
const { schema, cursor } = state;
|
|
192
|
+
const box = new Box({ width, padding: 1 });
|
|
377
193
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
let endIdx = Math.min(schema.length, startIdx + contentHeight);
|
|
383
|
-
|
|
384
|
-
// Adjust if at the end
|
|
385
|
-
if (endIdx - startIdx < contentHeight) {
|
|
386
|
-
startIdx = Math.max(0, endIdx - contentHeight);
|
|
387
|
-
}
|
|
194
|
+
const half = Math.floor(height / 2);
|
|
195
|
+
let start = Math.max(0, cursor - half);
|
|
196
|
+
let end = Math.min(schema.length, start + height);
|
|
197
|
+
if (end - start < height) start = Math.max(0, end - height);
|
|
388
198
|
|
|
389
|
-
|
|
390
|
-
const nameWidth = Math.min(30, Math.max(...schema.map(col => col.name.length)) + 2);
|
|
391
|
-
const typeWidth = Math.min(20, Math.max(...schema.map(col => col.type.length)) + 2);
|
|
392
|
-
const attrWidth = width - nameWidth - typeWidth - 10;
|
|
393
|
-
|
|
394
|
-
// Render schema rows
|
|
395
|
-
for (let i = startIdx; i < endIdx; i++) {
|
|
199
|
+
for (let i = start; i < end; i++) {
|
|
396
200
|
const col = schema[i];
|
|
397
201
|
const isSelected = i === cursor;
|
|
202
|
+
const rowBg = isSelected ? THEME.selectionBg : THEME.background;
|
|
398
203
|
|
|
399
|
-
const name = pad(
|
|
400
|
-
const type = pad(
|
|
401
|
-
|
|
402
|
-
// Build attributes string
|
|
403
|
-
const attrs: string[] = [];
|
|
204
|
+
const name = pad(col.name, 25);
|
|
205
|
+
const type = pad(col.type, 15);
|
|
206
|
+
const attrs = [];
|
|
404
207
|
if (col.pk) attrs.push('PK');
|
|
405
208
|
if (col.notnull) attrs.push('NOT NULL');
|
|
406
|
-
if (col.dflt_value !== null) attrs.push(`DEFAULT ${col.dflt_value}`);
|
|
407
|
-
const attrStr = attrs.length > 0 ? truncate(attrs.join(', '), attrWidth) : '';
|
|
408
209
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
210
|
+
let rowContent = `${ANSI.fg(isSelected ? THEME.primary : THEME.text)}${name}`;
|
|
211
|
+
rowContent += `${ANSI.fg(THEME.secondary)}${type}`;
|
|
212
|
+
rowContent += `${ANSI.fg(THEME.textDim)}${attrs.join(', ')}`;
|
|
213
|
+
lines.push(box.render(rowContent, { background: rowBg }));
|
|
214
|
+
}
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private renderRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
|
|
219
|
+
const allLines: string[] = [];
|
|
220
|
+
const { row, schema } = state;
|
|
221
|
+
const innerWidth = width - 2;
|
|
222
|
+
const box = new Box({ width, padding: 1, background: THEME.background });
|
|
223
|
+
|
|
224
|
+
// Calculate max label width for alignment
|
|
225
|
+
let maxLabelWidth = 0;
|
|
226
|
+
schema.forEach(col => {
|
|
227
|
+
maxLabelWidth = Math.max(maxLabelWidth, getVisibleWidth(col.name));
|
|
228
|
+
});
|
|
229
|
+
const labelPad = maxLabelWidth + 2; // +2 for ": "
|
|
230
|
+
|
|
231
|
+
schema.forEach((col) => {
|
|
232
|
+
const label = `${ANSI.bold}${ANSI.fg(THEME.secondary)}${pad(col.name, maxLabelWidth)}${ANSI.reset}: `;
|
|
233
|
+
const val = formatValue(row[col.name], undefined, true);
|
|
234
|
+
|
|
235
|
+
if (labelPad > innerWidth * 0.4) {
|
|
236
|
+
// Label too long, fallback to simpler layout
|
|
237
|
+
const simpleLabel = `${ANSI.bold}${ANSI.fg(THEME.secondary)}${col.name}${ANSI.reset}: `;
|
|
238
|
+
allLines.push(box.render(simpleLabel));
|
|
239
|
+
const wrappedLines = wrapText(val, innerWidth);
|
|
240
|
+
wrappedLines.forEach(line => {
|
|
241
|
+
allLines.push(box.render(`${ANSI.fg(THEME.text)}${line}`));
|
|
242
|
+
});
|
|
426
243
|
} else {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
244
|
+
const firstLineMax = innerWidth - labelPad;
|
|
245
|
+
const wrappedLines = wrapText(val, firstLineMax);
|
|
246
|
+
|
|
247
|
+
if (wrappedLines.length === 0 || (wrappedLines.length === 1 && wrappedLines[0] === '')) {
|
|
248
|
+
allLines.push(box.render(`${label}`));
|
|
249
|
+
} else {
|
|
250
|
+
allLines.push(box.render(`${label}${ANSI.fg(THEME.text)}${wrappedLines[0]}`));
|
|
251
|
+
|
|
252
|
+
if (wrappedLines.length > 1) {
|
|
253
|
+
wrappedLines.slice(1).forEach(line => {
|
|
254
|
+
allLines.push(box.render(`${' '.repeat(labelPad)}${ANSI.fg(THEME.text)}${line}`));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
433
258
|
}
|
|
434
|
-
|
|
435
|
-
lines.push(line);
|
|
436
|
-
}
|
|
259
|
+
});
|
|
437
260
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
lines.push(' '.repeat(width));
|
|
441
|
-
}
|
|
261
|
+
state.totalLines = allLines.length;
|
|
262
|
+
state.visibleHeight = height;
|
|
442
263
|
|
|
443
|
-
|
|
264
|
+
// Apply scroll offset
|
|
265
|
+
return allLines.slice(state.scrollOffset, state.scrollOffset + height);
|
|
444
266
|
}
|
|
445
267
|
|
|
446
|
-
|
|
447
|
-
* Build row detail view
|
|
448
|
-
*/
|
|
449
|
-
private buildRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
|
|
268
|
+
private renderHealth(state: HealthViewState, height: number, width: number): string[] {
|
|
450
269
|
const lines: string[] = [];
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
for (const col of schema) {
|
|
461
|
-
if (lineCount >= height) break;
|
|
462
|
-
|
|
463
|
-
const colName = `${COLORS.cyan}${pad(col.name, maxColNameLength)}${COLORS.reset}`;
|
|
464
|
-
const value = formatValue(row[col.name]);
|
|
465
|
-
|
|
466
|
-
// Truncate value to fit in single line
|
|
467
|
-
const truncatedValue = truncate(value, valueWidth);
|
|
468
|
-
|
|
469
|
-
let line = ` ${colName} ${COLORS.dim}:${COLORS.reset} ${truncatedValue}`;
|
|
470
|
-
const lineWidth = getVisibleWidth(line);
|
|
471
|
-
if (lineWidth < width - rightMargin) {
|
|
472
|
-
line += ' '.repeat(width - rightMargin - lineWidth);
|
|
473
|
-
}
|
|
474
|
-
lines.push(line);
|
|
475
|
-
lineCount++;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Fill remaining lines
|
|
479
|
-
while (lines.length < height) {
|
|
480
|
-
lines.push(' '.repeat(width));
|
|
481
|
-
}
|
|
482
|
-
|
|
270
|
+
const entries = Object.entries(state.info);
|
|
271
|
+
const box = new Box({ width, padding: 1, background: THEME.background });
|
|
272
|
+
|
|
273
|
+
entries.forEach(([key, val], idx) => {
|
|
274
|
+
if (idx >= height) return;
|
|
275
|
+
const label = pad(key.replace(/_/g, ' '), 25);
|
|
276
|
+
const rowContent = `${ANSI.fg(THEME.secondary)}${label}${ANSI.reset} : ${val}`;
|
|
277
|
+
lines.push(box.render(rowContent));
|
|
278
|
+
});
|
|
483
279
|
return lines;
|
|
484
280
|
}
|
|
485
281
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const { info } = state;
|
|
492
|
-
|
|
493
|
-
const title = `${COLORS.bold}Core health overview${COLORS.reset}`;
|
|
494
|
-
lines.push(pad(title, width));
|
|
495
|
-
|
|
496
|
-
const formatBool = (value: string): string => (value === '1' ? 'on' : value === '0' ? 'off' : value);
|
|
497
|
-
const formatAutoVacuum = (value: string): string => {
|
|
498
|
-
if (value === '0') return 'none';
|
|
499
|
-
if (value === '1') return 'full';
|
|
500
|
-
if (value === '2') return 'incremental';
|
|
501
|
-
return value;
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
const entries: Array<[string, string]> = [
|
|
505
|
-
['SQLite version', info.sqlite_version],
|
|
506
|
-
['Journal mode', info.journal_mode],
|
|
507
|
-
['Synchronous', info.synchronous],
|
|
508
|
-
['Locking mode', info.locking_mode],
|
|
509
|
-
['Page size', formatNumber(info.page_size)],
|
|
510
|
-
['Page count', formatNumber(info.page_count)],
|
|
511
|
-
['Freelist pages', formatNumber(info.freelist_count)],
|
|
512
|
-
['Cache size', formatNumber(info.cache_size)],
|
|
513
|
-
['WAL autocheckpoint', formatNumber(info.wal_autocheckpoint)],
|
|
514
|
-
['Auto vacuum', formatAutoVacuum(info.auto_vacuum)],
|
|
515
|
-
['User version', String(info.user_version)],
|
|
516
|
-
['Application id', String(info.application_id)],
|
|
517
|
-
['Encoding', info.encoding],
|
|
518
|
-
['Foreign keys', formatBool(info.foreign_keys)],
|
|
519
|
-
['Temp store', info.temp_store],
|
|
520
|
-
['Mmap size', formatNumber(info.mmap_size)],
|
|
521
|
-
['Busy timeout', `${formatNumber(info.busy_timeout)} ms`]
|
|
522
|
-
];
|
|
523
|
-
|
|
524
|
-
const labelWidth = Math.min(24, Math.max(...entries.map(([label]) => label.length)) + 1);
|
|
525
|
-
const contentWidth = width - labelWidth - 4;
|
|
526
|
-
|
|
527
|
-
for (const [label, value] of entries) {
|
|
528
|
-
if (lines.length >= height) break;
|
|
529
|
-
const labelText = pad(label, labelWidth, 'right');
|
|
530
|
-
const valueText = truncate(value, contentWidth);
|
|
531
|
-
const line = ` ${COLORS.cyan}${labelText}${COLORS.reset} ${COLORS.dim}:${COLORS.reset} ${valueText}`;
|
|
532
|
-
const lineWidth = getVisibleWidth(line);
|
|
533
|
-
if (lineWidth < width) {
|
|
534
|
-
lines.push(line + ' '.repeat(width - lineWidth));
|
|
535
|
-
} else {
|
|
536
|
-
const basicLine = ` ${labelText} : ${valueText}`;
|
|
537
|
-
lines.push(truncate(basicLine, width - 1));
|
|
538
|
-
}
|
|
282
|
+
private buildHelpBar(state: ViewState, width: number): string {
|
|
283
|
+
const box = new Box({ width, padding: 1, background: THEME.footerBg });
|
|
284
|
+
|
|
285
|
+
if ((state as any).notice) {
|
|
286
|
+
return box.render(`${ANSI.fg(THEME.textDim)}${(state as any).notice}`);
|
|
539
287
|
}
|
|
540
288
|
|
|
541
|
-
|
|
542
|
-
|
|
289
|
+
let helpItems: { key: string; label: string }[] = [];
|
|
290
|
+
switch (state.type) {
|
|
291
|
+
case 'tables':
|
|
292
|
+
helpItems = [
|
|
293
|
+
{ key: 'j/k', label: 'select' },
|
|
294
|
+
{ key: 'Enter/l', label: 'open' },
|
|
295
|
+
{ key: 'i', label: 'info' },
|
|
296
|
+
{ key: 'q', label: 'quit' }
|
|
297
|
+
];
|
|
298
|
+
break;
|
|
299
|
+
case 'table-detail':
|
|
300
|
+
helpItems = [
|
|
301
|
+
{ key: 'j/k', label: 'scroll' },
|
|
302
|
+
{ key: 'Enter/l', label: 'row' },
|
|
303
|
+
{ key: 's', label: 'schema' },
|
|
304
|
+
{ key: 'h', label: 'back' },
|
|
305
|
+
{ key: 'q', label: 'quit' }
|
|
306
|
+
];
|
|
307
|
+
break;
|
|
308
|
+
case 'schema-view':
|
|
309
|
+
helpItems = [
|
|
310
|
+
{ key: 'j/k', label: 'scroll' },
|
|
311
|
+
{ key: 's/h', label: 'back' },
|
|
312
|
+
{ key: 'q', label: 'quit' }
|
|
313
|
+
];
|
|
314
|
+
break;
|
|
315
|
+
case 'row-detail':
|
|
316
|
+
helpItems = [
|
|
317
|
+
{ key: 'j/k', label: 'switch' },
|
|
318
|
+
{ key: '↑/↓', label: 'scroll' },
|
|
319
|
+
{ key: 'h', label: 'back' },
|
|
320
|
+
{ key: 'q', label: 'quit' }
|
|
321
|
+
];
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
helpItems = [
|
|
325
|
+
{ key: 'h', label: 'back' },
|
|
326
|
+
{ key: 'q', label: 'quit' }
|
|
327
|
+
];
|
|
328
|
+
break;
|
|
543
329
|
}
|
|
544
330
|
|
|
545
|
-
|
|
546
|
-
|
|
331
|
+
const styledHelp = helpItems
|
|
332
|
+
.map(item => `${ANSI.fg(THEME.text)}${item.key} ${ANSI.fg(THEME.textDim)}${item.label}`)
|
|
333
|
+
.join(' ');
|
|
547
334
|
|
|
548
|
-
|
|
549
|
-
* Build help bar (bottom line)
|
|
550
|
-
*/
|
|
551
|
-
private buildHelpBar(state: ViewState, width: number): string {
|
|
552
|
-
let help = '';
|
|
553
|
-
const notice =
|
|
554
|
-
(state.type === 'table-detail' || state.type === 'row-detail') ? state.notice : undefined;
|
|
555
|
-
const deleteStep =
|
|
556
|
-
(state.type === 'table-detail' || state.type === 'row-detail') ? state.deleteConfirm?.step : undefined;
|
|
557
|
-
const isDeleteConfirm =
|
|
558
|
-
(state.type === 'table-detail' || state.type === 'row-detail') ? Boolean(state.deleteConfirm) : false;
|
|
559
|
-
|
|
560
|
-
if (notice && isDeleteConfirm) {
|
|
561
|
-
const stepLabel = deleteStep ? `Step ${deleteStep}/2` : '';
|
|
562
|
-
const actionLabel = deleteStep === 2 ? 'delete' : 'confirm';
|
|
563
|
-
help = ` ${COLORS.red}${COLORS.bold}${stepLabel ? `${stepLabel} ` : ''}${notice}${COLORS.reset} ${COLORS.yellow}[y] ${actionLabel}${COLORS.reset} [h/Esc] cancel`;
|
|
564
|
-
const paddedHelp = pad(help, width);
|
|
565
|
-
return paddedHelp;
|
|
566
|
-
} else if (notice) {
|
|
567
|
-
help = ` ${COLORS.yellow}${notice}${COLORS.reset}`;
|
|
568
|
-
const paddedHelp = pad(help, width);
|
|
569
|
-
return paddedHelp;
|
|
570
|
-
} else if (state.type === 'tables') {
|
|
571
|
-
help = ' [j/k] select [Enter/l] open [i] info [g/G] top/bottom [q] quit';
|
|
572
|
-
} else if (state.type === 'table-detail') {
|
|
573
|
-
help = ' [j/k] scroll [Enter/l] view row [Backspace] delete [s] toggle schema [h/Esc] back [q] quit';
|
|
574
|
-
} else if (state.type === 'schema-view') {
|
|
575
|
-
help = ' [j/k] scroll [g/G] top/bottom [s/h/Esc] back [q] quit';
|
|
576
|
-
} else if (state.type === 'row-detail') {
|
|
577
|
-
help = ' [Backspace] delete [h/Esc] back [q] quit';
|
|
578
|
-
} else if (state.type === 'health') {
|
|
579
|
-
help = ' [i] back [h/Esc] back [q] quit';
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const paddedHelp = pad(help, width);
|
|
583
|
-
return `${COLORS.dim}${paddedHelp}${COLORS.reset}`;
|
|
335
|
+
return box.render(styledHelp, { align: 'right' });
|
|
584
336
|
}
|
|
585
337
|
}
|