@@ -8,12 +8,13 @@ import {
88 TextRenderable ,
99 SelectRenderable ,
1010 SelectRenderableEvents ,
11- type KeyEvent ,
1211 type SelectOption ,
1312} from '@opentui/core' ;
1413import type { RenderContext , Renderer } from '../types' ;
15- import { theme , PALETTE , symbols } from '../../../assets/brand/theme' ;
14+ import { theme , symbols } from '../../../assets/brand/theme' ;
1615import type { Screen , ScreenResult , ScreenData } from '../utils/router' ;
16+ import { Keymap } from '../utils/keymap' ;
17+ import { appShell , panel , type Panel } from '../components' ;
1718
1819interface FileEntry {
1920 name : string ;
@@ -29,9 +30,9 @@ export class FilePicker implements Screen {
2930 private currentPath : string ;
3031 private entries : FileEntry [ ] = [ ] ;
3132 private select ?: SelectRenderable ;
32- private breadcrumb ?: TextRenderable ;
3333 private emptyMessage ?: TextRenderable ;
34- private keyHandler ?: ( key : KeyEvent ) => void ;
34+ private keymap ?: Keymap ;
35+ private filePanel ?: Panel ;
3536 private screenData ?: ScreenData ;
3637
3738 private title : string = 'Select CSV File' ;
@@ -69,42 +70,11 @@ export class FilePicker implements Screen {
6970 }
7071
7172 cleanup ( ) : void {
72- if ( this . keyHandler ) {
73- this . renderer . keyInput . off ( 'keypress' , this . keyHandler ) ;
74- }
73+ this . keymap ?. detach ( this . renderer ) ;
7574 this . renderer . root . remove ( CONTAINER_ID ) ;
7675 }
7776
7877 private buildUI ( resolve : ( result : ScreenResult ) => void ) : void {
79- // Root container
80- const container = new BoxRenderable ( this . renderer , {
81- id : CONTAINER_ID ,
82- flexDirection : 'column' ,
83- width : '100%' ,
84- height : '100%' ,
85- backgroundColor : theme . background ,
86- } ) ;
87-
88- // Header
89- const header = new BoxRenderable ( this . renderer , {
90- flexDirection : 'column' ,
91- } ) ;
92-
93- header . add (
94- new TextRenderable ( this . renderer , {
95- content : this . title ,
96- fg : theme . primary ,
97- } )
98- ) ;
99-
100- this . breadcrumb = new TextRenderable ( this . renderer , {
101- content : this . shortenPath ( this . currentPath ) ,
102- fg : theme . textMuted ,
103- } ) ;
104- header . add ( this . breadcrumb ) ;
105-
106- container . add ( header ) ;
107-
10878 // Always create both renderables; toggle visibility based on whether there
10979 // are entries. This avoids a one-way trap where starting in an empty directory
11080 // means this.select is never created and updateSelectOptions() can never show
@@ -117,7 +87,6 @@ export class FilePicker implements Screen {
11787 flexGrow : 1 ,
11888 visible : ! hasOptions ,
11989 } ) ;
120- container . add ( this . emptyMessage ) ;
12190
12291 this . select = new SelectRenderable ( this . renderer , {
12392 options : this . entriesToOptions ( ) ,
@@ -132,25 +101,63 @@ export class FilePicker implements Screen {
132101 flexGrow : 1 ,
133102 visible : hasOptions ,
134103 } ) ;
135- container . add ( this . select ) ;
136-
137- // Status bar
138- container . add (
139- new TextRenderable ( this . renderer , {
140- content : this . selectionMode === 'directory'
141- ? '[↑↓] Nav [ENTER] Open/Select [BACKSPACE] Up [ESC] Cancel'
142- : '[↑↓] Nav [ENTER] Select [BACKSPACE] Up Dir [ESC] Back' ,
143- fg : theme . textMuted ,
144- } )
145- ) ;
104+
105+ // Build keymap before the shell so its keybar can seed the footer
106+ this . keymap = new Keymap ( {
107+ onBack : ( ) => resolve ( { action : 'pop' } ) ,
108+ onQuit : ( ) => resolve ( { action : 'pop' } ) , // "q" also pops here — file-picker has no quit-to-desktop concept
109+ bindings : [
110+ // Nav hint — arrow keys handled by SelectRenderable; this is bar-only
111+ {
112+ keys : [ 'up' , 'down' , 'k' , 'j' ] ,
113+ hint : `${ symbols . arrows . up } ${ symbols . arrows . down } ` ,
114+ label : 'Nav' ,
115+ handler : ( ) => { } ,
116+ } ,
117+ {
118+ keys : [ 'enter' ] ,
119+ label : this . selectionMode === 'directory' ? 'Open/Select' : 'Select' ,
120+ handler : ( ) => this . select ?. selectCurrent ( ) ,
121+ } ,
122+ {
123+ keys : [ 'backspace' ] ,
124+ hint : 'BKSP' ,
125+ label : 'Up' ,
126+ handler : ( ) => {
127+ void this . goUpDirectory ( ) ;
128+ } ,
129+ } ,
130+ ] ,
131+ } ) ;
132+
133+ // Shell: header + breadcrumb, content region, footer keybar
134+ const shell = appShell ( this . renderer , {
135+ id : CONTAINER_ID ,
136+ breadcrumb : this . title ,
137+ footer : this . keymap . toKeybar ( ) ,
138+ } ) ;
139+
140+ // File-list panel — border title shows the current directory path
141+ this . filePanel = panel ( this . renderer , {
142+ title : this . shortenPath ( this . currentPath ) ,
143+ flexGrow : 1 ,
144+ } ) ;
145+ this . filePanel . add ( this . emptyMessage ) ;
146+ this . filePanel . add ( this . select ) ;
147+
148+ // Body: the file-list panel (room for a preview pane alongside it later)
149+ const body = new BoxRenderable ( this . renderer , { flexDirection : 'row' , flexGrow : 1 } ) ;
150+ body . add ( this . filePanel . box ) ;
151+ shell . content . add ( body ) ;
146152
147153 // Add to renderer
148- this . renderer . root . add ( container ) ;
154+ this . renderer . root . add ( shell . root ) ;
149155
150156 // Focus and wire events — select is always created
151157 if ( hasOptions ) {
152158 this . select . focus ( ) ;
153159 }
160+ this . keymap . attach ( this . renderer ) ;
154161
155162 this . select . on (
156163 SelectRenderableEvents . ITEM_SELECTED ,
@@ -173,9 +180,7 @@ export class FilePicker implements Screen {
173180 this . currentPath = entry . path ;
174181 await this . loadDirectory ( ) ;
175182 this . updateSelectOptions ( ) ;
176- if ( this . breadcrumb ) {
177- this . breadcrumb . content = this . shortenPath ( this . currentPath ) ;
178- }
183+ this . filePanel ?. setTitle ( this . shortenPath ( this . currentPath ) ) ;
179184 } else if ( this . workflowType === 'check-current' ) {
180185 // First step of check flow: selected current file, now pick previous
181186 resolve ( {
@@ -224,24 +229,17 @@ export class FilePicker implements Screen {
224229 }
225230 }
226231 ) ;
232+ }
227233
228- // Screen-level key handler
229- this . keyHandler = async ( key : KeyEvent ) => {
230- if ( key . name === 'backspace' ) {
231- const parent = path . dirname ( this . currentPath ) ;
232- if ( parent !== this . currentPath ) {
233- this . currentPath = parent ;
234- await this . loadDirectory ( ) ;
235- this . updateSelectOptions ( ) ;
236- if ( this . breadcrumb ) {
237- this . breadcrumb . content = this . shortenPath ( this . currentPath ) ;
238- }
239- }
240- } else if ( key . name === 'escape' || key . name === 'q' ) {
241- resolve ( { action : 'pop' } ) ;
242- }
243- } ;
244- this . renderer . keyInput . on ( 'keypress' , this . keyHandler ) ;
234+ /** Navigate to the parent directory (bound to Backspace via the keymap). */
235+ private async goUpDirectory ( ) : Promise < void > {
236+ const parent = path . dirname ( this . currentPath ) ;
237+ if ( parent === this . currentPath ) return ;
238+
239+ this . currentPath = parent ;
240+ await this . loadDirectory ( ) ;
241+ this . updateSelectOptions ( ) ;
242+ this . filePanel ?. setTitle ( this . shortenPath ( this . currentPath ) ) ;
245243 }
246244
247245 private updateSelectOptions ( ) : void {
0 commit comments