Skip to content

feat(layout): add FilePickerNode for file/folder selection#284

Merged
Aaronontheweb merged 9 commits into
devfrom
feature/file-picker
Jun 9, 2026
Merged

feat(layout): add FilePickerNode for file/folder selection#284
Aaronontheweb merged 9 commits into
devfrom
feature/file-picker

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Owner

Summary

  • Adds FilePickerNode — an interactive file/folder picker with breadcrumb navigation, keyboard-driven directory traversal, scrolling, fuzzy filtering, and single/multi-select support
  • New types: FilePickerMode (Files/Directories/All), FilePickerSelectionMode (Single/Multi), FileSystemEntry record, IFileSystemProvider abstraction with DefaultFileSystemProvider
  • Gallery demo page with side-by-side file picker (single file) and folder picker (directory select) using GridNode 50/50 layout
  • 28 unit tests covering navigation, selection, filtering, multi-select, glob filters, focus lifecycle, measure, and dispose

Key design decisions

  • Enter always navigates into directories (unless multi-select has toggled items, in which case Enter confirms); Space selects in Directories/All modes
  • ASCII icons ([D] / spaces) avoid surrogate-pair and column-width issues with emoji
  • Embedded TextInputNode for filter bar (reuses existing component, not registered with focus manager)
  • IFileSystemProvider abstraction enables deterministic testing with FakeFileSystemProvider

Test plan

  • dotnet test — all 1402 tests pass, 0 regressions
  • dotnet build on library, demo, and test projects — clean
  • Manual: run gallery demo, navigate to File Picker page, test keyboard navigation
  • Manual: verify filter mode (type to search, Escape clears, Backspace on empty exits)
  • Manual: verify Tab switches between pickers, Escape returns to menu
  • Manual: verify directory-only picker selects with Space, navigates with Enter

Interactive terminal file picker with breadcrumb navigation, fuzzy
filtering, and configurable selection modes (Files, Directories, All).
Supports single and multi-select, hidden file toggling, and glob-based
file filters via FileSystemName.MatchesSimpleExpression.

Key design decisions:
- Enter always navigates into directories; Space selects in
  Directories/All modes (avoids blocking subdirectory browsal)
- IFileSystemProvider abstraction enables deterministic testing
- ASCII icons avoid surrogate-pair/column-width issues with emoji
- Filter state properly resets on blur to prevent stale filtered views

Includes Gallery demo page with side-by-side file and folder pickers,
and 26 unit tests covering navigation, filtering, selection modes,
glob patterns, scroll behavior, and lifecycle.
- Fix Multi+Directories mode: Enter now confirms toggled selections
  instead of always navigating into the highlighted directory
- Clear _selectedPaths on directory navigation to prevent stale
  cross-directory paths from being emitted on confirmation
- Auto-include highlighted file in multi-select Enter confirmation
- Fix breadcrumb truncation crash (ArgumentOutOfRangeException) when
  render width is 4 or less
- Fix '/' then Backspace leaving user stuck in filter mode by moving
  empty-text check outside the handled guard
- Allow space characters in filter text by removing Spacebar
  interception in filter mode
- Fix entry truncation crash when contentWidth is 0
- Clamp availableForList to prevent negative listHeight in render
- Use SetFocus instead of PushFocus in gallery page to prevent
  unbounded focus stack growth
- Replace _entries.Count==0 sentinel with _isLoaded flag so empty
  directories don't re-trigger LoadDirectory on every focus
- Catch IOException alongside UnauthorizedAccessException in
  DefaultFileSystemProvider for network/device errors
- Add !_disposed guards on _selectionConfirmed and _cancelled
  Subject emissions to match Invalidate/EmitDirectoryChanged pattern
- Deactivate filter input before completing subjects in Dispose
- Align x-offset: breadcrumb, filter bar, and footer now render at
  x=0 consistently with file list entries
- Fix Slash_ActivatesFilterMode test to verify filter mode was
  actually entered before asserting cancellation
- Add tests for backspace-exits-empty-filter and Multi+Directories
  toggle-then-confirm workflows
- Add docs/components/file-picker-node.md covering usage, picker and
  selection modes, keyboard shortcuts, filtering, glob filters,
  styling, IFileSystemProvider testing, and full API reference
- Register FilePickerNode in the VitePress sidebar and components index
- Add gallery-file-picker.tape VHS script and recorded GIF showing
  directory navigation, type-to-filter, file and folder selection
- Lazy-load the directory on first render so unfocused pickers show
  content instead of "Empty directory" before receiving focus
- Add Layouts.FilePicker(startPath) factory matching the established
  pattern for SelectionList, Modal, Wizard, and other interactive nodes
- Switch docs examples and gallery demo page to use the factory
- Add unit test covering both the explicit-path and default overloads
Load lifecycle:
- Latch _isLoaded on failed loads so a nonexistent start path no longer
  retries filesystem I/O on every render frame; WithStartPath resets the
  latch to allow reloading
- Collapse three lazy-load sites into EnsureLoaded(); initial lazy loads
  no longer emit DirectoryChanged (navigation-only signal)

Filter input:
- Only ApplyFilter when the filter text actually changes — cursor
  movement (Left/Right) no longer resets the list highlight and scroll
- Unhandled keys (Tab, F-keys, Ctrl chords) now bubble instead of being
  swallowed and silently closing an empty filter; Backspace on an empty
  filter still exits filter mode explicitly
- Alt/Ctrl chords no longer hijacked into filter activation
- Filter input now receives OnFocused/OnBlurred so the cursor blink
  timer starts and stops correctly; its Invalidated is bridged to the
  picker's so blink frames repaint; TimeProvider is threaded through
  for testability
- LoadDirectory exits filter mode with full deactivation instead of
  just flipping the flag (no more orphaned blink timer)

Multi-select model:
- Selections persist across directory navigation; footer shows a
  running count (e.g. "3 selected")
- Enter on an untoggled directory always navigates (drill-in restored);
  Enter on a toggled directory confirms — behavior is predictable from
  the visible [x] on the highlighted row
- Confirming no longer mutates the toggle set; the highlighted file is
  appended to the emitted list only

Scroll math:
- EnsureVisible now tracks the rows actually rendered (_effectiveRows),
  fixing blind navigation when the parent allocates less space than the
  configured visible rows
- _scrollOffset re-clamped in Render so enlarging the terminal no
  longer strands the view past the end of the list
- Measure reserves a row for the empty-state message so it no longer
  overwrites the footer

Misc:
- DefaultFileSystemProvider.GetParentDirectory trims trailing
  separators and guards ArgumentException/IOException
- EmitSelectionConfirmed helper for consistent disposed-guarding
- Filter bar width off-by-one and empty-state x-offset cleanup
- Stale Cancelled XML doc corrected
- Gallery menu hint updated to [1-6] Quick Select; GIF re-recorded
- Docs updated for the new multi-select semantics; doc fake provider
  example now sorts per the GetEntries contract
- 9 new tests: cursor-move highlight preservation, unhandled-key
  bubbling, space-in-filter, cross-directory selection persistence,
  confirm non-mutation, All+Multi mode, untoggled-dir navigation,
  WithStartPath reload, bad-path no-retry; hidden-file tests made
  discriminating
The OnActivate/OnDeactivate tree-walk pattern-matched on the concrete
LayoutNode class at every dispatch site, so components implementing
ILayoutNode interfaces directly (FilePickerNode, SelectionListNode)
never received lifecycle calls. Their OnActivate/OnDeactivate methods
were dead code, which is why FilePickerNode had to lazy-load in
Render() as a workaround.

- LayoutNode now implements the existing IActivatableNode interface
  (its virtual OnActivate/OnDeactivate already satisfy it)
- All 36 activation dispatch sites across ContainerNode, GridNode,
  PanelNode, ModalNode, ConditionalNode, DynamicLayoutNode,
  KeyedDynamicLayoutNode, ReactiveLayoutNode, and ReactivePage now
  pattern-match on IActivatableNode instead of LayoutNode — behavior
  for LayoutNode subclasses is unchanged, and bare-interface nodes
  now participate in the page lifecycle
- FilePickerNode and SelectionListNode implement IActivatableNode,
  so directory loading happens at page activation instead of first
  render, and embedded input timers pause on navigation away
- Traversal-only LayoutNode checks (FocusManager child collection,
  WizardNode tree walks) are intentionally unchanged
- Render-time EnsureLoaded() retained as a fallback for nodes
  attached outside the activation flow

Adds tests verifying activation propagates through VerticalLayout and
GridNode to an interface-based FilePickerNode child.
"[BS] Up" was ambiguous — use "[Backspace] Up" in all four footer hint
variants. Re-record the gallery GIF to match.
@Aaronontheweb Aaronontheweb marked this pull request as ready for review June 9, 2026 20:36
- Custom-components guide now explains that lifecycle dispatch goes
  through IActivatableNode (LayoutNode implements it; interface-based
  components implement it directly) and the container propagation
  example matches on the interface instead of the concrete class —
  the old sample taught the exact gap fixed in d390749
- Replace the Timer/.Elapsed event example with Observable.Interval +
  TimeProvider, and the System.Reactive types (IObservable,
  BooleanDisposable) with R3 equivalents, per project conventions
- Add missing TextAreaNode and CopyableTextNode entries to the docs
  sidebar Input Components section
@Aaronontheweb Aaronontheweb enabled auto-merge (squash) June 9, 2026 20:40
@Aaronontheweb Aaronontheweb merged commit 5ef68d6 into dev Jun 9, 2026
13 checks passed
@Aaronontheweb Aaronontheweb deleted the feature/file-picker branch June 9, 2026 20:45
@Aaronontheweb Aaronontheweb mentioned this pull request Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant