Skip to content

Commit d28a969

Browse files
Merge pull request #81 from foundersandcoders/refactor/validation-explorer-app-shell
Bring the Validation Explorer Into the Frame
2 parents c8b6cfb + 6ae7750 commit d28a969

3 files changed

Lines changed: 418 additions & 91 deletions

File tree

src/tui/screens/validation-explorer.ts

Lines changed: 124 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,14 @@
22
* Interactive issue browser for validation results
33
* Supports filtering by severity (Errors/Warnings/All)
44
*/
5-
import {
6-
BoxRenderable,
7-
TextRenderable,
8-
SelectRenderable,
9-
TabSelectRenderable,
10-
} from '@opentui/core';
5+
import { BoxRenderable, TextRenderable, SelectRenderable, TabSelectRenderable } from '@opentui/core';
116
import type { RenderContext, Renderer } from '../types';
127
import { theme, symbols } from '../../../assets/brand/theme';
138
import type { Screen, ScreenResult, ScreenData } from '../utils/router';
149
import type { ValidationIssue, ValidationResult } from '../../lib/utils/csv/csvValidator';
1510
import type { SchemaValidationIssue } from '../../lib/types/schemaTypes';
11+
import { appShell, panel, type AppShell, type Panel } from '../components';
12+
import { Keymap } from '../utils/keymap';
1613

1714
const CONTAINER_ID = 'validation-explorer-root';
1815

@@ -31,10 +28,15 @@ interface DisplayIssue {
3128
export class ValidationExplorerScreen implements Screen {
3229
readonly name = 'validation-explorer';
3330
private renderer: Renderer;
34-
private container?: BoxRenderable;
31+
private shell?: AppShell;
32+
private filterPanel?: Panel;
33+
private issuesPanel?: Panel;
34+
private detailPanel?: Panel;
35+
private detailContainer?: BoxRenderable;
36+
private keymap?: Keymap;
3537
private tabs?: TabSelectRenderable;
3638
private issueList?: SelectRenderable;
37-
private detailPanel?: BoxRenderable;
39+
private activePanel: 'issues' | 'detail' = 'issues';
3840

3941
private allIssues: DisplayIssue[] = [];
4042
private currentFilter: TabFilter = 'all';
@@ -56,16 +58,19 @@ export class ValidationExplorerScreen implements Screen {
5658
// Normalise issues
5759
this.allIssues = this.normaliseIssues(validation, sourceType);
5860

59-
this.buildUI(validation.errorCount, validation.warningCount);
60-
6161
// Wait for user interaction
6262
return new Promise((resolve) => {
63+
const keymap = this.buildUI(validation.errorCount, validation.warningCount, resolve);
64+
6365
// Tab change handler
6466
this.tabs?.on('selectionChanged', (_index: number, option: { value?: string }) => {
6567
const tabValue = (option.value || 'all') as TabFilter;
6668
this.currentFilter = tabValue;
6769
this.updateIssueList();
68-
// Move focus to issue list after selecting a tab
70+
// Move focus back to the issues pane after selecting a filter
71+
this.activePanel = 'issues';
72+
this.issuesPanel?.setFocused(true);
73+
this.detailPanel?.setFocused(false);
6974
this.issueList?.focus();
7075
});
7176

@@ -74,28 +79,34 @@ export class ValidationExplorerScreen implements Screen {
7479
this.updateDetailPanel(index);
7580
});
7681

77-
// Keyboard handlers
78-
const handler = (key: { name: string }) => {
79-
if (key.name === 'escape' || key.name === 'q') {
80-
this.renderer.keyInput.off('keypress', handler);
81-
resolve({ action: 'replace', screen: 'dashboard' });
82-
} else if (key.name === 'left') {
83-
this.tabs?.moveLeft();
84-
} else if (key.name === 'right') {
85-
this.tabs?.moveRight();
86-
}
87-
};
88-
this.renderer.keyInput.on('keypress', handler);
89-
90-
// Focus issue list initially (tabs can be switched with left/right while list is focused)
82+
// Focus issue list initially
9183
this.issueList?.focus();
84+
this.issuesPanel?.setFocused(true);
85+
86+
keymap.attach(this.renderer);
9287
});
9388
}
9489

9590
cleanup(): void {
91+
this.keymap?.detach(this.renderer);
9692
this.renderer.root.remove(CONTAINER_ID);
9793
}
9894

95+
/** Flip focus between the Issues and Detail panes (bound to Tab via the keymap). */
96+
private togglePanel(): void {
97+
if (!this.detailPanel) return;
98+
99+
this.activePanel = this.activePanel === 'issues' ? 'detail' : 'issues';
100+
this.issuesPanel?.setFocused(this.activePanel === 'issues');
101+
this.detailPanel?.setFocused(this.activePanel === 'detail');
102+
103+
if (this.activePanel === 'issues') {
104+
this.issueList?.focus();
105+
} else {
106+
this.issueList?.blur();
107+
}
108+
}
109+
99110
private normaliseIssues(
100111
validation:
101112
| ValidationResult
@@ -135,71 +146,93 @@ export class ValidationExplorerScreen implements Screen {
135146
);
136147
}
137148

138-
private buildUI(errorCount: number, warningCount: number): void {
139-
this.container = new BoxRenderable(this.renderer, {
140-
id: CONTAINER_ID,
141-
flexDirection: 'column',
142-
width: '100%',
143-
height: '100%',
144-
backgroundColor: theme.background,
149+
private buildUI(
150+
errorCount: number,
151+
warningCount: number,
152+
resolve: (result: ScreenResult) => void
153+
): Keymap {
154+
const finish = () => resolve({ action: 'replace', screen: 'dashboard' });
155+
156+
this.keymap = new Keymap({
157+
bindings: [
158+
// Nav hint — arrow keys handled by SelectRenderable; this is bar-only
159+
{
160+
keys: ['up', 'down', 'k', 'j'],
161+
hint: `${symbols.arrows.up}${symbols.arrows.down}`,
162+
label: 'Navigate',
163+
handler: () => {},
164+
},
165+
{ keys: ['tab'], label: 'Switch Pane', handler: () => this.togglePanel() },
166+
{
167+
keys: ['left', 'h'],
168+
hint: `${symbols.arrows.left}${symbols.arrows.right}`,
169+
label: 'Filter',
170+
handler: () => this.tabs?.moveLeft(),
171+
},
172+
{ keys: ['right', 'l'], label: 'Filter', hidden: true, handler: () => this.tabs?.moveRight() },
173+
],
174+
onBack: finish,
175+
onQuit: finish,
145176
});
177+
const keymap = this.keymap;
146178

147-
// Title
148-
const title = new TextRenderable(this.renderer, {
149-
content: 'Validation Issues',
150-
fg: theme.primary,
179+
this.shell = appShell(this.renderer, {
180+
id: CONTAINER_ID,
181+
breadcrumb: 'Validation Issues',
182+
footer: keymap.toKeybar(),
151183
});
152-
this.container.add(title);
153184

154185
// Summary
155-
const summary = new TextRenderable(this.renderer, {
156-
content: `${errorCount} errors, ${warningCount} warnings`,
157-
fg: theme.textMuted,
158-
});
159-
this.container.add(summary);
186+
this.shell.content.add(
187+
new TextRenderable(this.renderer, {
188+
content: `${errorCount} errors, ${warningCount} warnings`,
189+
fg: theme.textMuted,
190+
})
191+
);
160192

161-
this.container.add(new TextRenderable(this.renderer, { content: '' }));
193+
this.shell.content.add(new TextRenderable(this.renderer, { content: '' }));
162194

163-
// Tabs
195+
// Filter bar
164196
this.tabs = new TabSelectRenderable(this.renderer, {
165197
options: [
166198
{ name: 'Errors', description: '', value: 'errors' },
167199
{ name: 'Warnings', description: '', value: 'warnings' },
168200
{ name: 'All', description: '', value: 'all' },
169201
],
170202
});
171-
this.container.add(this.tabs);
203+
// TabSelectRenderable always highlights index 0 on mount; align it with currentFilter ('all').
204+
this.tabs.setSelectedIndex(2);
205+
this.filterPanel = panel(this.renderer, { title: 'Filter' });
206+
this.filterPanel.add(this.tabs);
207+
this.shell.content.add(this.filterPanel.box);
172208

173-
this.container.add(new TextRenderable(this.renderer, { content: '' }));
209+
this.shell.content.add(new TextRenderable(this.renderer, { content: '' }));
174210

175211
// Issue list (initially empty, will be populated by updateIssueList)
176212
this.issueList = new SelectRenderable(this.renderer, {
177213
options: [],
178214
showScrollIndicator: true,
215+
flexGrow: 1,
179216
});
180-
this.container.add(this.issueList);
181217

182-
this.container.add(new TextRenderable(this.renderer, { content: '' }));
218+
this.issuesPanel = panel(this.renderer, { title: 'Issues', flexGrow: 1, focused: true });
219+
this.issuesPanel.add(this.issueList);
183220

184-
// Detail panel
185-
this.detailPanel = new BoxRenderable(this.renderer, {
186-
flexDirection: 'column',
187-
});
188-
this.container.add(this.detailPanel);
189-
190-
this.container.add(new TextRenderable(this.renderer, { content: '' }));
221+
this.detailContainer = new BoxRenderable(this.renderer, { flexDirection: 'column' });
222+
this.detailPanel = panel(this.renderer, { title: 'Detail', flexGrow: 1 });
223+
this.detailPanel.add(this.detailContainer);
191224

192-
// Status bar
193-
const statusBar = new TextRenderable(this.renderer, {
194-
content: '[↑↓] Navigate [←→] Switch filter [ESC/q] Back',
195-
fg: theme.textMuted,
196-
});
197-
this.container.add(statusBar);
225+
const body = new BoxRenderable(this.renderer, { flexDirection: 'row', flexGrow: 1 });
226+
body.add(this.issuesPanel.box);
227+
body.add(this.detailPanel.box);
228+
this.shell.content.add(body);
198229

199-
this.renderer.root.add(this.container);
230+
this.renderer.root.add(this.shell.root);
200231

201232
// Initial population
202233
this.updateIssueList();
234+
235+
return keymap;
203236
}
204237

205238
private updateIssueList(): void {
@@ -230,12 +263,12 @@ export class ValidationExplorerScreen implements Screen {
230263
}
231264

232265
private updateDetailPanel(index: number): void {
233-
if (!this.detailPanel) return;
266+
if (!this.detailContainer) return;
234267

235268
// Clear existing content by removing all children
236-
const children = this.detailPanel.getChildren();
269+
const children = this.detailContainer.getChildren();
237270
for (const child of children) {
238-
this.detailPanel.remove(child.id);
271+
this.detailContainer.remove(child.id);
239272
}
240273

241274
// Filter issues based on current filter
@@ -249,110 +282,110 @@ export class ValidationExplorerScreen implements Screen {
249282
if (!issue) return;
250283

251284
// Count occurrences of this error pattern
252-
const samePattern = filtered.filter(
253-
(i) => i.code === issue.code && i.field === issue.field
254-
);
285+
const samePattern = filtered.filter((i) => i.code === issue.code && i.field === issue.field);
255286
if (samePattern.length > 1) {
256287
const position = samePattern.indexOf(issue) + 1;
257-
this.detailPanel.add(new TextRenderable(this.renderer, {
258-
content: `Occurrence ${position} of ${samePattern.length}`,
259-
fg: theme.textMuted,
260-
}));
261-
this.detailPanel.add(new TextRenderable(this.renderer, { content: '' }));
288+
this.detailContainer.add(
289+
new TextRenderable(this.renderer, {
290+
content: `Occurrence ${position} of ${samePattern.length}`,
291+
fg: theme.textMuted,
292+
})
293+
);
294+
this.detailContainer.add(new TextRenderable(this.renderer, { content: '' }));
262295
}
263296

264297
// Field
265298
const fieldLabel = new TextRenderable(this.renderer, {
266299
content: 'Field:',
267300
fg: theme.textMuted,
268301
});
269-
this.detailPanel.add(fieldLabel);
302+
this.detailContainer.add(fieldLabel);
270303

271304
const fieldValue = new TextRenderable(this.renderer, {
272305
content: issue.field,
273306
fg: theme.text,
274307
});
275-
this.detailPanel.add(fieldValue);
308+
this.detailContainer.add(fieldValue);
276309

277-
this.detailPanel.add(new TextRenderable(this.renderer, { content: '' }));
310+
this.detailContainer.add(new TextRenderable(this.renderer, { content: '' }));
278311

279312
// Row
280313
if (issue.row !== undefined) {
281314
const rowLabel = new TextRenderable(this.renderer, {
282315
content: 'Row:',
283316
fg: theme.textMuted,
284317
});
285-
this.detailPanel.add(rowLabel);
318+
this.detailContainer.add(rowLabel);
286319

287320
const rowValue = new TextRenderable(this.renderer, {
288321
content: String(issue.row + 1), // Display as 1-indexed for non-dev users
289322
fg: theme.text,
290323
});
291-
this.detailPanel.add(rowValue);
324+
this.detailContainer.add(rowValue);
292325

293-
this.detailPanel.add(new TextRenderable(this.renderer, { content: '' }));
326+
this.detailContainer.add(new TextRenderable(this.renderer, { content: '' }));
294327
}
295328

296329
// Code
297330
const codeLabel = new TextRenderable(this.renderer, {
298331
content: 'Code:',
299332
fg: theme.textMuted,
300333
});
301-
this.detailPanel.add(codeLabel);
334+
this.detailContainer.add(codeLabel);
302335

303336
const codeValue = new TextRenderable(this.renderer, {
304337
content: issue.code,
305338
fg: theme.text,
306339
});
307-
this.detailPanel.add(codeValue);
340+
this.detailContainer.add(codeValue);
308341

309-
this.detailPanel.add(new TextRenderable(this.renderer, { content: '' }));
342+
this.detailContainer.add(new TextRenderable(this.renderer, { content: '' }));
310343

311344
// Message
312345
const messageLabel = new TextRenderable(this.renderer, {
313346
content: 'Message:',
314347
fg: theme.textMuted,
315348
});
316-
this.detailPanel.add(messageLabel);
349+
this.detailContainer.add(messageLabel);
317350

318351
const messageValue = new TextRenderable(this.renderer, {
319352
content: issue.message,
320353
fg: issue.severity === 'error' ? theme.error : theme.warning,
321354
});
322-
this.detailPanel.add(messageValue);
355+
this.detailContainer.add(messageValue);
323356

324357
// Value (if present)
325358
if (issue.value !== undefined) {
326-
this.detailPanel.add(new TextRenderable(this.renderer, { content: '' }));
359+
this.detailContainer.add(new TextRenderable(this.renderer, { content: '' }));
327360

328361
const valueLabel = new TextRenderable(this.renderer, {
329362
content: 'Value:',
330363
fg: theme.textMuted,
331364
});
332-
this.detailPanel.add(valueLabel);
365+
this.detailContainer.add(valueLabel);
333366

334367
const valueStr = JSON.stringify(issue.value);
335368
const valueValue = new TextRenderable(this.renderer, {
336369
content: valueStr,
337370
fg: theme.text,
338371
});
339-
this.detailPanel.add(valueValue);
372+
this.detailContainer.add(valueValue);
340373
}
341374
}
342375

343376
private clearDetailPanel(): void {
344-
if (!this.detailPanel) return;
377+
if (!this.detailContainer) return;
345378

346379
// Clear existing content by removing all children
347-
const children = this.detailPanel.getChildren();
380+
const children = this.detailContainer.getChildren();
348381
for (const child of children) {
349-
this.detailPanel.remove(child.id);
382+
this.detailContainer.remove(child.id);
350383
}
351384

352385
const emptyText = new TextRenderable(this.renderer, {
353386
content: 'No issues to display',
354387
fg: theme.textMuted,
355388
});
356-
this.detailPanel.add(emptyText);
389+
this.detailContainer.add(emptyText);
357390
}
358391
}

0 commit comments

Comments
 (0)