Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/path-box/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-03
53 changes: 53 additions & 0 deletions openspec/changes/path-box/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
## Context

See proposal.md — Why. Today `State.command_line: LineEdit` is the default sink for printable keys over the panels; `map_panel_key` computes `typing = !command_line.is_empty()` and flips the meaning of Backspace, Up/Down, Left/Right and `+`/`-`/`*` on it, and `handle_enter` runs the buffer before acting on the cursor entry. The reducer is pure (application-shell "The reducer performs no filesystem I/O"): `cd` answers from the panel listing when it can and otherwise emits `Effect::ProbePath` with a request id, applied by `apply_path_probed` only when still current. Find-file already navigates a panel and lands the cursor on a named entry through `PanelState::pending_cursor_target`, settled at `ListingComplete`. Type-ahead matches names case-insensitively by prefix on `Entry::sort_key()`. The destination dialog and `cd` share `resolve_cd_target`. `draw_text_field` renders every dialog text field; `render_command_line` already left-truncates a long prompt with `…`.

## Goals / Non-Goals

**Goals:**
- Reach any folder or file on disk by typing a prefix and pressing Tab a few times, then Enter.
- Land the panel cursor on the named entry, including a file, not just enter folders.
- Never change what a panel key does based on hidden text state; the panels keep their NC key map.
- Pure reducer, no UI-thread I/O; stale replies dropped as everywhere else.

**Non-Goals:**
- A popup candidate list, completion history, fuzzy matching, or completing anything but the last segment.
- Making plain printable keys do something over the panels (a follow-up could start type-ahead).
- Any shell. Ctrl+O and the Run / F2 suspended spawn move specs but do not change.

## Decisions

**D1 — The path box is a panels-level input mode, not an overlay.** `State.path_box: Option<PathBoxState { side, edit: LineEdit, completion: Option<Completion>, probe: Option<u64> }>` lives beside `quick_search` and the quick filter in the panels layer (application-shell precedence order item 6). While it is `Some`, the TUI routes every key through `map_path_box_key` before `map_panel_key`; keys the box does not bind are ignored, so no F-key, Alt+letter or Ctrl+chord reaches the panels and the box can never be left open under a menu it opened itself. If anything above the panels in the precedence order opens for another reason (a worker-driven dialog, the startup warning, a file-operation dialog), the box is closed and reset, as a type-ahead ends. *Alternative considered:* an `Overlay::PathBox` — rejected because overlays hide the terminal cursor, exclude the menu bar's hit map, and the box must coexist with the panels' rendering and mini-status.

**D2 — Focus by chord or click; Tab stays the panel switch.** `Keys.focus_path` defaults to Ctrl+G (Ctrl+L is info mode; Ctrl+Y is freed by this change but reads as "clear", not "go"). A click on either box dispatches `PathBoxFocus(side)`, which also makes that panel active so the box, cursor and mini-status agree. On focus the text is `cwd` plus the platform separator with the caret at the end (`LineEdit::set_text_caret_end`). Inside the box: `map_text_edit_key` (the full dialog set), Ctrl+V and Shift+Ins → `PathBoxPaste`, Tab → `PathBoxComplete { reverse: false }`, Shift+Tab (`BackTab`) → `{ reverse: true }`, Enter → `PathBoxAccept`, Esc → `PathBoxCancel`. A mouse click outside the focused box dispatches `PathBoxCancel` and is then handled normally.

**D3 — Completion state is derived from the text and cached per folder.** On Tab the reducer splits the text at the last separator into `(folder_text, prefix)`, resolves `folder_text` with `resolve_cd_target` against the box's panel `cwd` (so `..\`, `D:x\`, `\\srv\share\` and relative text work) and compares it with `completion.dir`. If equal and `names` is loaded it advances the cycle; otherwise it starts a new completion: if the folder equals either panel's `cwd` and that listing is `Complete`, the names come from that listing at once; else it emits `Effect::ListCompletions { request, dir }` and the mini-status shows `Reading…` until `Command::CompletionsListed { request, dir, names: Option<Vec<(OsString, bool)>> }` arrives. `names` holds every readable child, no `..`; the worker reads through `FsReader::read_dir` in a new `listing::list_children_for_completion`. Candidates are the names whose `sort_key()`-style lowercase form starts with the lowercase prefix, ordered by `(is_file, lowercase name)` — directories first, then files. This is a small comparator of its own: the panel's `cmp_entries` / `cmp_by_name` deliberately interleave directories and files (classic NC order) and are not reused here. The prefix used for matching is frozen when the cycle starts so repeated Tabs cycle instead of narrowing. Applying a candidate replaces the last segment with the on-disk name and appends a separator for a directory. Any text mutation other than Tab/Shift+Tab sets `index = None` but keeps `names` while `dir` is unchanged, so backspacing and retyping do not re-read. A reply whose `request` is not outstanding, or whose `dir` differs from the folder the current text names, is discarded; Tab during an outstanding read is ignored; `names: None` (unreadable) yields `no matches`.

**D4 — Enter reuses the cd probe and the find-file cursor landing.** `PathBoxAccept` resolves the whole text (ignoring one trailing separator) with `resolve_cd_target`. If it equals the panel `cwd` → close the box. If `parent_path` is `None` (drive root, UNC share root, `/`) → `begin_listing(side, path)`, close. Otherwise `(parent, name)`: when `parent` equals the box's panel `cwd` and its complete listing holds an entry matching `name` case-insensitively → immediate; when it equals the other panel's complete listing → likewise; else `Effect::ProbePath` with `ProbeIntent::Select { parent, name }` and `pending_note = Checking <path>…`. The reply is applied only if `path_box` is still focused on that side with the same text and the probe id is current; otherwise it is dropped silently. On success: if `parent != cwd`, `begin_listing(side, parent)` then `pending_cursor_target = Some(name)`; when already there, move the cursor directly with the same lookup `jump_to_prefix` uses, clearing a quick filter that hides the target; close the box. On failure: `last_error` names the path (filesystem-error-handling "Error messages name the affected path"); the box keeps focus and text. `ProbeIntent::{Cd, Delete}` are deleted; `PendingProbe { request, side, cwd, path, intent }` keeps its shape.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify root paths before closing the box

For a nonexistent drive root such as Z:\ or a misspelled UNC share root, parent_path is None, so this branch immediately changes the panel directory and closes the box without probing. The eventual listing failure therefore loses the editable path, contrary to the stated failure behavior that keeps focus and text; roots also need an off-thread existence check before navigation and closure.

Useful? React with 👍 / 👎.


**D5 — `pending_cursor_target` matches case-insensitively as a fallback.** `ListingComplete` first looks for an exact `OsString` match (find-file's existing contract), then for the first entry whose `sort_key()` equals the lowercased target. A probe-confirmed `readme.MD` then lands on `README.md` without the worker canonicalising the name. Find-file is unaffected because its names come from disk.

**D6 — Rendering splits the row at the panel divider and reuses the field renderer.** `layout::compute` keeps `cmdline` as the row above the key bar (core's `panel_viewport_rows` is untouched); the view derives two rects from the left and right panel rects on that row. An unfocused box draws `<cwd><sep>` in `Role::CommandLine`, left-truncated with `…` when wider than the box. `left_truncate_with_ellipsis` exists as a private function in both `views/command_line.rs` and `views/header.rs`; it is promoted to one shared helper in `views/mod.rs` and applied to the whole box width rather than half the row. The focused box draws its `LineEdit` through `draw_text_field` in the dialog text-field style and returns the caret, which becomes `RenderOutput.cursor`; with no focused box and no dialog field the terminal cursor is hidden. The mini-status override of the box's panel shows `k/n matches`, `no matches` or `Reading…` while a completion is active, in the slot the type-ahead pattern uses today. The hit map records `path_boxes: [Rect; 2]`.

**D7 — Keys over the panels lose their "typing" dependence.** `map_panel_key` drops `typing`: Backspace is always `ParentDir`, Up/Down always move the cursor, Left/Right are unbound, `+`/`-`/`*` always group-select, Ctrl+Backspace/Ctrl+H are unbound, Ctrl+V and bracketed paste over unfocused panels are ignored, and a plain printable key over the panels is ignored (no sink). Esc keeps requesting quit over the panels; inside a focused box it cancels the box. Alt+letter type-ahead and the Ctrl+P quick filter are unchanged; focusing the box ends type-ahead.

**D8 — History: the file keeps its shape, the app stops using commands.** `HistoryFile { commands, directories }` and the merge-on-write writer stay, so two instances still preserve each other's data and the Ctrl+J directory history is untouched. `State.history` / `history_cursor`, `config::push_history`, the Alt+F8 dialog, the Commands → History item and `MenuAction::History` are removed; `Effect::PersistHistory(HistoryFile)` keeps its shape, but the `HistoryFile` it carries always has an empty `commands`; the writer's existing merge (`merge_history_files`, fuzzy-jump "Two instances keep each other's history") keeps the file's `commands` array through unchanged.

**D9 — Config keys.** `key.focus_path` joins the overridable bindings (default `ctrl+g`). `key.paste_name`, `key.paste_path` and `key.clear_line` are no longer recognised, so an old `config.toml` that sets them gets the existing unrecognised-key startup warning (configuration-files "Malformed or unrecognized configuration lines produce startup warnings") — honest, and it tells the user what to delete. No migration.

**D10 — Interaction with `auto-refresh` (in flight).** Completion also treats a streaming listing as unavailable (path-box "Completion candidates come from a panel listing when possible"): only a `Complete` listing answers, so a partial listing never yields a partial candidate set. Its design D3 eligibility list (no overlay, drag or type-ahead) gains "no focused path box", so an automatic re-read never replaces the listing a completion cycle was answered from. Whichever change is built second adds the guard.

**D11 — Scenario titles that still say "command line" are kept on purpose.** `openspec validate` rejects a MODIFIED block that drops or renames a scenario, so the restated requirements keep titles such as "Backspace on empty command line goes to the parent" and "Non-empty command buffer takes precedence" while their bodies describe the path box. A later housekeeping change may retitle them directly in the main specs.

## Risks / Trade-offs

- [Breaking for `cd` / `del` / `rmdir` users] → `del` / `rmdir` have F8 and Delete; `cd` is exactly what the box does better. README and Help say so.
- [Printable keys over the panels do nothing] → Matches the dedicated-focus choice; a later change can route them into type-ahead. Help's "Path box" topic and the README state Ctrl+G up front.
- [Completion reads a whole directory] → One read per folder per cycle on a worker thread, cached while the folder text is unchanged; a huge folder costs one enumeration, as listing it does. An unreachable share shows `Reading…` and never blocks input; a reply for abandoned text is dropped.
- [Snapshot churn] → The prompt row appears in 66 of the 138 `.snap` files; they regenerate with the change.
- [Ctrl+G availability] → A plain control code delivered by every terminal; rebindable anyway.
- [Case folding] → Prefix and target matching use `sort_key()` (Unicode lowercase); on case-sensitive filesystems two entries differing only by case both appear as candidates, and Enter lands on the exact match first (D5).

## Open Questions

None blocking. Follow-ups considered: start type-ahead from a plain printable key; a candidate popup; a small `Reading…` indicator in the box itself.
Loading
Loading