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' ;
116import type { RenderContext , Renderer } from '../types' ;
127import { theme , symbols } from '../../../assets/brand/theme' ;
138import type { Screen , ScreenResult , ScreenData } from '../utils/router' ;
149import type { ValidationIssue , ValidationResult } from '../../lib/utils/csv/csvValidator' ;
1510import type { SchemaValidationIssue } from '../../lib/types/schemaTypes' ;
11+ import { appShell , panel , type AppShell , type Panel } from '../components' ;
12+ import { Keymap } from '../utils/keymap' ;
1613
1714const CONTAINER_ID = 'validation-explorer-root' ;
1815
@@ -31,10 +28,15 @@ interface DisplayIssue {
3128export 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