dbn-cli 0.3.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/README.md +1 -1
- package/package.json +5 -5
- package/src/adapter/adapter.test.ts +207 -0
- package/src/adapter/base.ts +14 -1
- package/src/adapter/sqlite.ts +71 -1
- package/src/index.ts +59 -2
- package/src/repro_bug.test.ts +101 -0
- package/src/types.ts +54 -1
- 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 +385 -27
- package/src/ui/navigator_sync.test.ts +95 -0
- package/src/ui/renderer.ts +247 -409
- 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
|
-
import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState } from '../types.ts';
|
|
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,464 +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}`;
|
|
50
|
+
if (state.type === 'table-detail' || state.type === 'schema-view' || state.type === 'row-detail') {
|
|
51
|
+
breadcrumb += ` > ${state.tableName}`;
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 = '';
|
|
58
60
|
if (state.type === 'tables') {
|
|
59
|
-
|
|
60
|
-
const currentPos = state.cursor + 1;
|
|
61
|
-
rightInfo = `${COLORS.dim}${currentPos}/${totalTables} tables${COLORS.reset}`;
|
|
61
|
+
rightPart = `${state.cursor + 1}/${state.tables.length} tables`;
|
|
62
62
|
} else if (state.type === 'table-detail') {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
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)}`;
|
|
66
65
|
} else if (state.type === 'schema-view') {
|
|
67
|
-
|
|
68
|
-
const currentPos = state.cursor + 1;
|
|
69
|
-
rightInfo = `${COLORS.dim}${currentPos}/${totalCols} columns${COLORS.reset}`;
|
|
66
|
+
rightPart = `${state.cursor + 1}/${state.schema.length} columns`;
|
|
70
67
|
} else if (state.type === 'row-detail') {
|
|
71
|
-
|
|
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
|
-
}
|
|
68
|
+
rightPart = `${state.schema.length} fields`;
|
|
103
69
|
}
|
|
104
|
-
}
|
|
105
70
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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);
|
|
111
78
|
}
|
|
112
79
|
|
|
113
|
-
/**
|
|
114
|
-
* Build main content area
|
|
115
|
-
*/
|
|
116
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
|
+
|
|
117
84
|
if (state.type === 'tables') {
|
|
118
|
-
|
|
85
|
+
content = this.renderTables(state, height, width);
|
|
119
86
|
} else if (state.type === 'table-detail') {
|
|
120
|
-
|
|
87
|
+
content = this.renderTableDetail(state, height, width);
|
|
121
88
|
} else if (state.type === 'schema-view') {
|
|
122
|
-
|
|
89
|
+
content = this.renderSchema(state, height, width);
|
|
123
90
|
} else if (state.type === 'row-detail') {
|
|
124
|
-
|
|
91
|
+
content = this.renderRowDetail(state, height, width);
|
|
92
|
+
} else if (state.type === 'health') {
|
|
93
|
+
content = this.renderHealth(state, height, width);
|
|
125
94
|
}
|
|
126
|
-
|
|
95
|
+
|
|
96
|
+
// Fill remaining lines with background
|
|
97
|
+
while (content.length < height) {
|
|
98
|
+
content.push(bgBox.render(''));
|
|
99
|
+
}
|
|
100
|
+
return content;
|
|
127
101
|
}
|
|
128
102
|
|
|
129
|
-
|
|
130
|
-
* Build tables list view
|
|
131
|
-
*/
|
|
132
|
-
private buildTablesList(state: TablesViewState, height: number, width: number): string[] {
|
|
103
|
+
private renderTables(state: TablesViewState, height: number, width: number): string[] {
|
|
133
104
|
const lines: string[] = [];
|
|
134
105
|
const { tables, cursor } = state;
|
|
106
|
+
const box = new Box({ width, padding: 1 });
|
|
135
107
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
}
|
|
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);
|
|
154
112
|
|
|
155
|
-
for (let i =
|
|
156
|
-
const table = tables[i];
|
|
113
|
+
for (let i = start; i < end; i++) {
|
|
157
114
|
const isSelected = i === cursor;
|
|
115
|
+
const table = tables[i];
|
|
158
116
|
|
|
159
|
-
|
|
160
|
-
const
|
|
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
|
-
}
|
|
117
|
+
const name = isSelected ? `${ANSI.bold}${table.name}` : `${table.name}`;
|
|
118
|
+
const count = `${formatNumber(table.row_count)} rows`;
|
|
188
119
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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}`;
|
|
193
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
|
+
}
|
|
194
130
|
return lines;
|
|
195
131
|
}
|
|
196
132
|
|
|
197
|
-
|
|
198
|
-
* Build table detail view
|
|
199
|
-
*/
|
|
200
|
-
private buildTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
|
|
133
|
+
private renderTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
|
|
201
134
|
const lines: string[] = [];
|
|
202
|
-
const { data,
|
|
135
|
+
const { data, dataOffset, dataCursor, bufferOffset } = state;
|
|
136
|
+
const box = new Box({ width, padding: 1 });
|
|
203
137
|
|
|
204
|
-
if (data.length === 0) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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;
|
|
211
145
|
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
146
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
154
|
}
|
|
353
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
|
+
|
|
354
186
|
return lines;
|
|
355
187
|
}
|
|
356
188
|
|
|
357
|
-
|
|
358
|
-
* Build schema view (full screen)
|
|
359
|
-
*/
|
|
360
|
-
private buildSchemaView(state: SchemaViewState, height: number, width: number): string[] {
|
|
189
|
+
private renderSchema(state: SchemaViewState, height: number, width: number): string[] {
|
|
361
190
|
const lines: string[] = [];
|
|
362
|
-
const { schema, cursor
|
|
191
|
+
const { schema, cursor } = state;
|
|
192
|
+
const box = new Box({ width, padding: 1 });
|
|
363
193
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
}
|
|
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);
|
|
382
198
|
|
|
383
|
-
|
|
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++) {
|
|
199
|
+
for (let i = start; i < end; i++) {
|
|
390
200
|
const col = schema[i];
|
|
391
201
|
const isSelected = i === cursor;
|
|
202
|
+
const rowBg = isSelected ? THEME.selectionBg : THEME.background;
|
|
392
203
|
|
|
393
|
-
const name = pad(
|
|
394
|
-
const type = pad(
|
|
395
|
-
|
|
396
|
-
// Build attributes string
|
|
397
|
-
const attrs: string[] = [];
|
|
204
|
+
const name = pad(col.name, 25);
|
|
205
|
+
const type = pad(col.type, 15);
|
|
206
|
+
const attrs = [];
|
|
398
207
|
if (col.pk) attrs.push('PK');
|
|
399
208
|
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
209
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
+
});
|
|
420
243
|
} else {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
+
}
|
|
427
258
|
}
|
|
428
|
-
|
|
429
|
-
lines.push(line);
|
|
430
|
-
}
|
|
259
|
+
});
|
|
431
260
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
lines.push(' '.repeat(width));
|
|
435
|
-
}
|
|
261
|
+
state.totalLines = allLines.length;
|
|
262
|
+
state.visibleHeight = height;
|
|
436
263
|
|
|
437
|
-
|
|
264
|
+
// Apply scroll offset
|
|
265
|
+
return allLines.slice(state.scrollOffset, state.scrollOffset + height);
|
|
438
266
|
}
|
|
439
267
|
|
|
440
|
-
|
|
441
|
-
* Build row detail view
|
|
442
|
-
*/
|
|
443
|
-
private buildRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
|
|
268
|
+
private renderHealth(state: HealthViewState, height: number, width: number): string[] {
|
|
444
269
|
const lines: string[] = [];
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
477
279
|
return lines;
|
|
478
280
|
}
|
|
479
281
|
|
|
480
|
-
/**
|
|
481
|
-
* Build help bar (bottom line)
|
|
482
|
-
*/
|
|
483
282
|
private buildHelpBar(state: ViewState, width: number): string {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (state
|
|
487
|
-
|
|
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';
|
|
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}`);
|
|
494
287
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
288
|
+
|
|
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;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const styledHelp = helpItems
|
|
332
|
+
.map(item => `${ANSI.fg(THEME.text)}${item.key} ${ANSI.fg(THEME.textDim)}${item.label}`)
|
|
333
|
+
.join(' ');
|
|
334
|
+
|
|
335
|
+
return box.render(styledHelp, { align: 'right' });
|
|
498
336
|
}
|
|
499
337
|
}
|