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.
@@ -1,10 +1,12 @@
1
- import { COLORS, BORDERS, UI } from './theme.ts';
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 for ncdu-style TUI
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
- // Build all lines
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(this.buildSeparator(width));
28
-
29
- const contentLines = this.buildContent(state, height - 3, width);
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 title = ` ${fileName}`;
48
+ let breadcrumb = ` ${fileName}`;
45
49
 
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
- } 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
- title = `${COLORS.bold}${title}${COLORS.reset}`;
57
-
58
- // Build right info
59
- let rightInfo = '';
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
- const totalTables = state.tables.length;
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 totalRows = formatNumber(state.totalRows);
66
- const currentRow = state.dataOffset + state.dataCursor + 1;
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
- const totalCols = state.schema.length;
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
- const colCount = state.schema.length;
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
- * Build separator line
112
- */
113
- private buildSeparator(width: number): string {
114
- return `${COLORS.dim}${BORDERS.horizontal.repeat(width)}${COLORS.reset}`;
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
- return this.buildTablesList(state, height, width);
85
+ content = this.renderTables(state, height, width);
123
86
  } else if (state.type === 'table-detail') {
124
- return this.buildTableDetail(state, height, width);
87
+ content = this.renderTableDetail(state, height, width);
125
88
  } else if (state.type === 'schema-view') {
126
- return this.buildSchemaView(state, height, width);
89
+ content = this.renderSchema(state, height, width);
127
90
  } else if (state.type === 'row-detail') {
128
- return this.buildRowDetail(state, height, width);
91
+ content = this.renderRowDetail(state, height, width);
129
92
  } else if (state.type === 'health') {
130
- return this.buildHealthView(state, height, width);
93
+ content = this.renderHealth(state, height, width);
131
94
  }
132
- return [];
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
- if (tables.length === 0) {
143
- lines.push('');
144
- lines.push(pad('No tables found', width, 'center'));
145
- while (lines.length < height) {
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 = startIdx; i < endIdx; 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
- // Build line without color codes first
166
- const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
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
- // Fill remaining lines
196
- while (lines.length < height) {
197
- lines.push(' '.repeat(width));
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, totalRows, dataOffset, dataCursor } = state;
135
+ const { data, dataOffset, dataCursor, bufferOffset } = state;
136
+ const box = new Box({ width, padding: 1 });
209
137
 
210
- if (data.length === 0) {
211
- lines.push(pad('No data', width, 'center'));
212
- } else {
213
- // Get column names
214
- const columns = Object.keys(data[0]);
215
-
216
- // Calculate optimal column widths based on content
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
- // Fill remaining lines
356
- while (lines.length < height) {
357
- lines.push(' '.repeat(width));
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, scrollOffset } = state;
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
- // Calculate visible window
379
- const contentHeight = height; // Use full height
380
- const halfHeight = Math.floor(contentHeight / 2);
381
- let startIdx = Math.max(0, cursor - halfHeight);
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
- // Calculate column widths
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(truncate(col.name, nameWidth - 1), nameWidth);
400
- const type = pad(truncate(col.type, typeWidth - 1), typeWidth);
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
- // Build content without selection color first
410
- const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
411
- const content = `${cursorChar} ${name} ${COLORS.cyan}${type}${COLORS.reset} ${COLORS.dim}${attrStr}${COLORS.reset}`;
412
-
413
- // Calculate visible width (accounting for CJK and ANSI codes)
414
- const contentWidth = getVisibleWidth(content);
415
-
416
- // Build line with exact width
417
- const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
418
- let line: string;
419
- if (contentWidth < safeWidth) {
420
- line = content + ' '.repeat(safeWidth - contentWidth);
421
- } else if (contentWidth > safeWidth) {
422
- // Need to truncate - build without colors first
423
- const basicContent = `${cursorChar} ${name} ${type} ${attrStr}`;
424
- const truncatedBasic = truncate(basicContent, safeWidth);
425
- line = truncatedBasic;
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
- line = content;
428
- }
429
-
430
- // Apply inverse color for selected row (wraps entire line)
431
- if (isSelected) {
432
- line = COLORS.inverse + line + COLORS.reset;
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
- // Fill remaining lines
439
- while (lines.length < height) {
440
- lines.push(' '.repeat(width));
441
- }
261
+ state.totalLines = allLines.length;
262
+ state.visibleHeight = height;
442
263
 
443
- return lines;
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 { row, schema, tableName, rowIndex } = state;
452
-
453
- // Get max column name length for alignment
454
- const maxColNameLength = Math.max(...schema.map(col => col.name.length), 10);
455
- const rightMargin = 2; // Right side margin
456
- const valueWidth = width - maxColNameLength - 5 - rightMargin;
457
-
458
- // Display each field (one line per field)
459
- let lineCount = 0;
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
- * Build health overview view
488
- */
489
- private buildHealthView(state: HealthViewState, height: number, width: number): string[] {
490
- const lines: string[] = [];
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
- while (lines.length < height) {
542
- lines.push(' '.repeat(width));
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
- return lines;
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
  }