Skip to content

Commit 5f23f60

Browse files
Merge pull request #77 from foundersandcoders/refactor/file-picker-app-shell
2 parents 03f737b + c8fa018 commit 5f23f60

3 files changed

Lines changed: 142 additions & 75 deletions

File tree

docs/roadmaps/v5a-2606/enhanced.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ because the shell rollout and signature features build on them.
1414
| Phase | Focus | Status |
1515
|-------|--------------------------------------------------------------|--------|
1616
| **A** | Foundations (theme, layout primitives, keymap) | Complete |
17-
| **S** | Security & dependency maintenance (Dependabot) | In Progress (S1 done, S2 open) |
18-
| **B** | App-shell rollout across screens | Blocked (needs S2) |
17+
| **S** | Security & dependency maintenance (Dependabot) | Complete |
18+
| **B** | App-shell rollout across screens | In Progress (B1, B2 done) |
1919
| **C** | Signature UX features (help, toasts, progress, transitions) | Ready |
2020
| **D** | Polish (palette, command palette, dark mode, schema display) | Blocked (needs B) |
2121
| **E** | Tutorial & demo resources (Charm VHS recordings) | Blocked (needs B/C) |
@@ -178,11 +178,42 @@ Not TUI redesign work, but tracked here since this is the active roadmap.
178178
One branch per screen cluster; each adopts the shell, bordered panels, the keymap
179179
registry, and a header breadcrumb.
180180

181-
- [ ] **TR.B1** `refactor/dashboard-app-shell` — Dashboard onto shell + panels +
181+
- [x] **TR.B1** `refactor/dashboard-app-shell` — Dashboard onto shell + panels +
182182
keymap; add a **Recent Activity** panel sourced from submission history.
183-
**depends on TR.S2**
184-
- [ ] **TR.B2** `refactor/file-picker-app-shell` — File picker; framed list +
185-
(later) preview panel; consistent nav keys. — **depends on TR.S2**
183+
**depends on TR.S2**. **Done:** rebuilt on `appShell()` + `panel()`
184+
(menu panel + Recent Activity panel side by side), `Keymap` drives the
185+
footer keybar (nav hint, number shortcuts, quit/back both resolve
186+
`quit` at root). Recent Activity shows the 5 most recent submissions
187+
from `createStorage().loadHistory()`, newest-first, with an
188+
"Unknown date" fallback for unparseable timestamps. Merged via PR #76.
189+
- [x] **TR.B2** `refactor/file-picker-app-shell` — File picker; framed list +
190+
(later) preview panel; consistent nav keys. — **depends on TR.S2**.
191+
**Done:** replaced the hand-rolled root/header/footer/keypress-listener
192+
with `appShell()` + `panel()` + `Keymap`, following the TR.B1 dashboard
193+
as the reference adopter. Header breadcrumb shows the screen title
194+
(e.g. "Select CSV File"); the file-list panel's **border title shows
195+
the live current-directory path**, updating on both directory-entry
196+
navigation and Backspace-up. `Keymap` bindings: nav hint (bar-only,
197+
`SelectRenderable` owns arrows), Enter (`selectCurrent()`), Backspace
198+
(extracted into `goUpDirectory()`); both ESC and `q` pop (file-picker
199+
has no quit-to-desktop concept, unchanged from pre-refactor
200+
behaviour). All caller data contracts preserved unchanged: the
201+
`check-current`/`check-previous` two-step flow, `mapping-create`,
202+
default single-file workflows, and the `selectionMode: 'directory'` +
203+
`__select__` sentinel + `fieldKey` pop-back used by Settings. The
204+
"(later) preview panel" is deferred — the body is a row `BoxRenderable`
205+
wrapping the single file-list panel, so a second pane can be added
206+
alongside it without restructuring. Added two tests
207+
(`tests/tui/screens/file-picker.test.ts`) asserting the footer keybar
208+
and the panel-title path display; all other existing tests updated to
209+
match the new shell/keymap wiring where needed. Verified manually via
210+
`bun run cli` in a real terminal (tmux-driven): Convert flow, Settings'
211+
directory-mode picker (sentinel select + `fieldKey` round-trip),
212+
directory navigation in/out with live title updates, ESC/backspace
213+
behaviour. `bun run test:svelte` 53/53 files, 600/600 tests green;
214+
`bun run test:core` 474/474 unaffected; no new `tsc` diagnostics (the
215+
5 pre-existing `updateSelectOptions` possibly-undefined errors predate
216+
this branch, confirmed via `git stash` comparison).
186217
- [ ] **TR.B3** `refactor/workflow-app-shell` — Processing screen into the frame
187218
(sets up TR.C3). — **depends on TR.S2**
188219
- [ ] **TR.B4** `refactor/results-screens-app-shell` — validation-explorer +

src/tui/screens/file-picker.ts

Lines changed: 67 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import {
88
TextRenderable,
99
SelectRenderable,
1010
SelectRenderableEvents,
11-
type KeyEvent,
1211
type SelectOption,
1312
} from '@opentui/core';
1413
import type { RenderContext, Renderer } from '../types';
15-
import { theme, PALETTE, symbols } from '../../../assets/brand/theme';
14+
import { theme, symbols } from '../../../assets/brand/theme';
1615
import type { Screen, ScreenResult, ScreenData } from '../utils/router';
16+
import { Keymap } from '../utils/keymap';
17+
import { appShell, panel, type Panel } from '../components';
1718

1819
interface 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 {

tests/tui/screens/file-picker.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,44 @@ describe('FilePicker', () => {
138138
expect(shortened).toBe('/tmp/data.csv');
139139
});
140140

141+
it('renders a footer keybar containing "Nav" and "Select" driven by the keymap', async () => {
142+
const screen = new FilePicker(mockContext);
143+
144+
(fs.readdir as any).mockResolvedValue(filePickerFixtures.mixedDirectory);
145+
146+
screen.render(); // Don't await
147+
148+
await new Promise((resolve) => setTimeout(resolve, 10));
149+
150+
// The shell root is the single child added to renderer.root
151+
const shellRoot = (mockContext.renderer.root.add as any).mock.calls[0][0];
152+
const children = shellRoot.getChildren();
153+
// Last child is the footer TextRenderable (header, content, footer)
154+
const footer = children[children.length - 1];
155+
expect(footer.constructor.name).toBe('TextRenderable');
156+
const footerText: string = footer.content.chunks[0].text;
157+
expect(footerText).toContain('Nav');
158+
expect(footerText).toContain('Select');
159+
160+
screen.cleanup();
161+
});
162+
163+
it('shows the current directory path as the file-list panel title', async () => {
164+
const screen = new FilePicker(mockContext);
165+
166+
(fs.readdir as any).mockResolvedValue(filePickerFixtures.mixedDirectory);
167+
168+
screen.render(); // Don't await
169+
170+
await new Promise((resolve) => setTimeout(resolve, 10));
171+
172+
const filePanel = (screen as any).filePanel;
173+
expect(filePanel).toBeDefined();
174+
expect(filePanel.box.title).toBe((screen as any).shortenPath(process.cwd()));
175+
176+
screen.cleanup();
177+
});
178+
141179
it('shows empty message when no CSV files found', async () => {
142180
const screen = new FilePicker(mockContext);
143181

0 commit comments

Comments
 (0)