From 5fbc4438c85f555ed73c058bf337e5e2dba3671e Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Wed, 7 Oct 2026 19:38:56 -0600 Subject: [PATCH 01/13] Add OpenSpec proposal ui-animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decorative ASCII animations for the startup splash (frame trace, identity-line reveal, title sweep inside the 800 ms hold, then a panel wipe), the F9 menu bar and every pop-up menu (sweep, top-down unfold, highlight settle), and every modal dialog (100 ms iris). All motion is render-only interpolation of the reducer's Tick clock against open-time stamps, so core::update stays pure, input is never blocked, and insta snapshots stay deterministic. Disabled via `animations = false` or `--noanimations` (flag wins); no new theme roles, so user theme files keep validating. Supersedes design doc §4.10's no-animation rule for decorative chrome only; loading feedback stays static. Adds the new ui-animations capability and deltas for startup-splash and pulldown-menus. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/ui-animations/.openspec.yaml | 2 + openspec/changes/ui-animations/design.md | 81 +++++++++++ openspec/changes/ui-animations/proposal.md | 35 +++++ .../specs/pulldown-menus/spec.md | 21 +++ .../specs/startup-splash/spec.md | 60 ++++++++ .../ui-animations/specs/ui-animations/spec.md | 129 ++++++++++++++++++ openspec/changes/ui-animations/tasks.md | 38 ++++++ 7 files changed, 366 insertions(+) create mode 100644 openspec/changes/ui-animations/.openspec.yaml create mode 100644 openspec/changes/ui-animations/design.md create mode 100644 openspec/changes/ui-animations/proposal.md create mode 100644 openspec/changes/ui-animations/specs/pulldown-menus/spec.md create mode 100644 openspec/changes/ui-animations/specs/startup-splash/spec.md create mode 100644 openspec/changes/ui-animations/specs/ui-animations/spec.md create mode 100644 openspec/changes/ui-animations/tasks.md diff --git a/openspec/changes/ui-animations/.openspec.yaml b/openspec/changes/ui-animations/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/ui-animations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/ui-animations/design.md b/openspec/changes/ui-animations/design.md new file mode 100644 index 0000000..9b6c6f7 --- /dev/null +++ b/openspec/changes/ui-animations/design.md @@ -0,0 +1,81 @@ +# Design: ui-animations + +## Context + +The TUI event loop (`filecommand-tui/src/app.rs`) polls crossterm with a 33 ms timeout; every timeout delivers `Command::Tick(now)` from the injected monotonic `Clock`, the reducer stores it in `State::clock_ms`, and the loop redraws. The startup splash already uses this heartbeat for its 800 ms minimum hold (`UiPhase::Splash { started_at_ms }`). All rendering is a pure function of `State` through `views::render`, every overlay is positioned by `dialogs::overlay_rect`, and every color comes from a theme role. User theme files in `themes/*.toml` are validated against the full role list — a missing role is a hard error. Snapshot tests build `State` directly via `State::empty` and pin the clock. + +## Goals / Non-Goals + +**Goals:** + +- Decorative motion on the splash, the panel reveal after it, the F9 bar and all pop-up menus, and every modal dialog — Norton Commander glyphs and colors only. +- Zero impact on responsiveness: state transitions are instant; animations are interpolation of elapsed time at render; nothing waits for an animation to finish. +- Deterministic and snapshot-testable; existing snapshots unchanged. +- One switch to turn it all off (`animations = false`, `--noanimations`), yielding today's exact rendering. +- No new theme roles; no breakage for user theme files. + +**Non-Goals:** + +- Animating loading feedback (design doc §4.10's static-text rule stands for listings, Info panel, drive select, progress dialog counters/gauge). +- Multiple intensity levels, OS reduced-motion detection, or a runtime Options toggle. +- Changing any overlay's final geometry, content, timing (800 ms hold), or key handling. +- ASCII-art logos or new glyphs outside the CP437 set. + +## Decisions + +### D1: Animations are render-only interpolation of `clock_ms − stamp`; the reducer only records when things opened + +The reducer transitions state exactly as today and additionally stamps the open time of the affected overlay with `state.clock_ms` (the last `Tick` reading — no clock read inside `update`, so it stays pure). Views compute `elapsed = state.clock_ms.saturating_sub(stamp)` and draw the frame for that instant via helpers in a new `filecommand-core::anim` module (`progress(elapsed, duration) -> 0..=1000` per-mille, `reveal_count(progress, total)`, `sweep_pos(progress, width)`). Because the next `Tick` lands ≤ 33 ms later and always marks the frame dirty, a 100 ms animation gets ~4 frames with no event-loop changes. Alternative considered: an `Effect::Animate` that schedules extra redraws — rejected; the heartbeat already exists. Alternative: passing a real `now` into `render` — rejected; using `clock_ms` for both stamp and now keeps `render(State)` a pure function of state and makes snapshots trivially reproducible. Trade-off: sustained key-repeat faster than 33 ms suppresses ticks and holds an in-flight animation on its current frame until input pauses; accepted (the state underneath is already final). + +### D2: No new theme roles — animation colors reuse existing roles + +Adding roles would make every existing user `themes/*.toml` fail validation. Instead: splash reveal intermediate = `splash.frame`; splash title sweep window = `splash.version`; pop-up highlight settle = `menu.hotkey`; unrevealed cells = `screen.backdrop` (or the surface's own body style). This keeps the splash inside its spec'd `splash.*` roles and holds for hue-free themes (`nc-mono`, `inverted`, `terminal-green`, `yellow-storm`): the structural motion (trace, unfold, iris, wipe) is glyph-based and theme-independent, and the color steps degrade to subtle or invisible differences without ever carrying meaning. Alternative considered: optional `anim.*` roles with built-in defaults when absent — rejected as a second role-resolution path for a purely decorative feature. + +### D3: One shared "reveal" helper renders the final surface to a scratch buffer and blits a sub-rectangle + +For the dialog iris and the panel wipe, the view renders the finished surface into a scratch `ratatui::Buffer` sized to its final rectangle, then copies onto the screen only the cells inside the reveal window — a centered rectangle growing from 2×1 to full size (iris) or the top `n` rows (wipe). This needs no per-dialog changes: `render_phase` wraps whichever modal dialog view is active. Content under the window is the *finished* dialog, so the user sees the real dialog growing, never placeholder art. Hit-maps are built from final geometry, so a click during the 100 ms iris lands where the button will be. Alternative: per-dialog frame-drawing animations — rejected; ~12 dialog views would each need edits. + +### D4: Stamps live on the state that opened + +- `MenuState { opened_at_ms, pulldown_opened_at_ms, selected_at_ms }` — `pulldown_opened_at_ms` is re-stamped on Left/Right traversal and hotkey jumps (the new pull-down unfolds while open and interactive); `selected_at_ms` on every selection change. +- `UserMenuState { opened_at_ms, selected_at_ms }`, `FileActionMenuState { opened_at_ms, selected_at_ms }` — same unfold/settle treatment via one shared pop-up helper. +- `State::dialog_opened_at_ms` — set whenever a modal dialog becomes visible (phase enters `FileOpSetup`/`FileOpRunning`/`FileOpSummary`, or `help`/`theme_picker`/`drive_select`/`fuzzy_jump`/`find_file` become `Some`). Only one of these is visible at a time, so one stamp suffices; a replacement dialog (e.g. progress → conflict) re-stamps and irises again. +- `State::quit_opened_at_ms` — the quit dialog layers over others, so it has its own stamp and the underlying dialog does not re-animate. +- `State::panels_revealed_at_ms: Option` — set on the `Splash → Panels` transition only (hold elapsed or key). `None` when the splash was skipped, so frame 1 with `--nosplash` is still the complete panels. +- `UiPhase::Splash { started_at_ms }` already exists and drives the splash entrance. + +### D5: `State::animations` defaults off in `State::empty`, on in `Config::default` + +`Config::default().animations = true` so real launches animate; `apply_config` copies it into `State::animations`; `State::empty` sets `false`. Every view checks `state.animations` first and short-circuits to the final frame when off. Result: all existing snapshot and reducer tests (which construct `State::empty`) are unchanged; animation snapshots opt in by setting the flag and pinning `clock_ms`. + +### D6: Fixed timing budget, all ≤ the existing splash hold + +| Animation | Duration | Notes | +|---|---|---| +| Splash frame trace | 0–200 ms | perimeter cells revealed clockwise from top-left | +| Splash identity reveal | 200–560 ms | line *i* starts at 200 + 80·i, lasts 120 ms, reveals outward from center; revealed chars in `splash.frame` for ≤ 66 ms, then final role | +| Splash title sweep | 560–760 ms | 3-cell `splash.version` window crosses the product name once | +| Panel wipe after splash | 150 ms | top-to-bottom rows | +| Menu bar sweep | 100 ms | left-to-right columns | +| Pop-up menu unfold | 100 ms | frame top row first, rows added downward, bottom edge tracks | +| Highlight settle | 50 ms | new highlight row in `menu.hotkey` style, then `menu.highlight` | +| Dialog iris | 100 ms | centered window from 2×1 to full rectangle | + +Durations are constants in `core::anim` so tests reference them by name. Everything on the splash completes by 760 ms, inside the unchanged 800 ms minimum hold. + +### D7: Disable precedence mirrors `splash`/`mouse` + +`animations = false` in the general options of `config.toml`, or `--noanimations` at launch; the flag wins when they disagree. Parsed in `config::parse` next to `splash` and in `parse_launch_args` next to `--nosplash`; listed in `print_usage` and the README. The design doc §4.10 sentence is superseded for decorative chrome only; this change is the normative record of that. + +## Risks / Trade-offs + +- [Mid-animation frame appears in a snapshot that did not intend it] → `State::empty` has animations off; only tests that set `animations = true` see motion, and they pin `clock_ms` at named offsets. +- [Dialog iris briefly hides the dialog title so a user cannot read what opened for ~100 ms] → 100 ms is ≈ 4 frames; the window reveals the center (title/prompt region) first; disable flag for users who dislike it. +- [Scratch-buffer blit every frame while a dialog irises] → bounded to ≤ 4 frames per open; a dialog never exceeds the terminal minus the 2-cell overlay margin (the largest, the Help window, is 62×19 at 80×24), so each blit is at most a few thousand cells. +- [Hue-free themes show little color change] → structural motion carries the effect; color steps are decorative, consistent with the mono-theme rule that color never carries meaning. +- [Key-repeat starvation of `Tick` freezes an animation mid-way] → state is already final; animation resumes/completes on the next idle poll. Accepted (D1). +- [Progress/conflict/error dialogs iris during a file job] → dialog state is final and Cancel/Retry buttons are live from frame 1; the iris is cosmetic. + +## Open Questions + +- None. Surfaces, intensity, disable mechanism, and design-doc scoping were settled with the user. diff --git a/openspec/changes/ui-animations/proposal.md b/openspec/changes/ui-animations/proposal.md new file mode 100644 index 0000000..662de75 --- /dev/null +++ b/openspec/changes/ui-animations/proposal.md @@ -0,0 +1,35 @@ +# Change: ui-animations + +## Why + +FileCommand's chrome appears and disappears instantaneously: the splash box pops in fully formed, F9 drops a complete pull-down in one frame, dialogs snap open. That is faithful to a 1990s text-mode redraw, but it leaves no room for the small flourishes a modern terminal can afford — a frame tracing itself in, a menu unfolding, a highlight settling — that make the application feel alive and give visual continuity between states. The user asked for ASCII animations on application load and in the menus, as color changes and more elaborate UI motion, in keeping with the Norton Commander 5.5 aesthetic. + +The design doc (§4.10) says FileCommand "never uses spinners or animation glyphs" for *loading feedback*. This change deliberately supersedes that rule for **decorative chrome** — splash, menus, dialogs — while keeping every loading affordance (streaming listings, `Reading… N`, Info-panel `…`, drive-label fill-in) exactly as static as today. + +## What Changes + +- **Motion model** (new `ui-animations` capability): every animation is a pure function of time elapsed since a state transition, computed from the reducer's existing `Tick` clock reading. The reducer transitions state instantly and only records *when* an overlay opened; the renderer interpolates. Input is never blocked, no hold is lengthened, and any key applies to the final state immediately. +- **Startup splash entrance**: the double-line frame traces itself clockwise from the top-left corner, the four identity lines reveal outward from their centers (appearing first in the frame color, then settling to their final role), and a single highlight sweep crosses the product name — all inside the existing 800 ms minimum hold. After the splash, the panels wipe in top-to-bottom over 150 ms. +- **Menus**: on F9 the menu bar sweeps in left-to-right; every pop-up menu (F9 pull-downs, F2 user menu, Enter file-action menu) unfolds top-down from its frame; when the highlighted item changes, the new highlight row renders one frame in the hotkey accent before settling to the normal highlight style. +- **Modal dialogs**: every modal dialog opens with a 100 ms "iris" — a growing centered window onto the finished dialog — instead of appearing fully formed. +- **Configuration**: `animations = true` (default) in `config.toml` general options; `--noanimations` launch flag; flag wins. When disabled, every surface renders its final frame immediately — pixel-identical to today. +- **No new theme roles**: all animation colors come from roles that already exist (`splash.frame`, `splash.version`, `menu.hotkey`, `screen.backdrop`), so user `themes/*.toml` files keep validating. +- **Determinism**: animation progress is derived from `State::clock_ms`, so `insta` snapshot tests pin a clock and capture mid-animation frames; states built without config keep animations off, so existing snapshots are unchanged. + +## Capabilities + +### New Capabilities + +- `ui-animations`: the render-only motion model and its clock source; the `animations` config key and `--noanimations` flag; the modal-dialog iris; the pop-up menu unfold and highlight settle (shared by F9 pull-downs, F2 user menu, Enter action menu); the post-splash panel reveal; and the scoping that loading feedback remains static. + +### Modified Capabilities + +- `startup-splash`: "Frame-1 splash rendering" is reworded so frame 1 is the splash *backdrop plus the first frame of the entrance* when animations are enabled (the complete box when disabled); a new "Splash entrance animation" requirement defines the frame trace, identity-line reveal, and title sweep within the 800 ms hold. Minimum-hold, key-dismissal, disable, and resize requirements are unchanged. +- `pulldown-menus`: adds "Menu bar entrance sweep" for the F9 bar; existing bar, pull-down, navigation, and content requirements are unchanged (horizontal traversal still opens the adjacent pull-down with no intermediate closed state — it unfolds while already open and interactive). + +## Impact + +- **Crates:** `filecommand-core` — new `anim` module (progress/easing helpers, duration constants); `opened_at_ms`/`selected_at_ms` stamps on `MenuState`, `UserMenuState`, `FileActionMenuState`; `State::animations` flag, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; reducer sets stamps from `state.clock_ms` where overlays open; `config.rs` `animations` key. `filecommand-tui` — `--noanimations` in `parse_launch_args`, `apply_config` wiring, `print_usage` line; `splash.rs`, `menubar.rs`, `user_menu.rs`, `file_action_menu.rs` interpolated rendering; a shared scratch-buffer "reveal" helper in `views/mod.rs` used by the dialog iris and the panel wipe; new snapshot tests. +- **Docs:** README flag table and `config.toml` key table gain `--noanimations` / `animations`. +- **Depends on:** `startup-splash` (clock + hold), `pulldown-menus`, `user-menu`, `file-action-menu`, `responsive-layout` (overlay geometry), `theme-system` (role-only colors). The `help-flags` change's usage block gains the new flag. +- **Out of scope:** animating loading feedback (listings, Info panel, drive select, progress counters); a reduced-motion/"subtle" intermediate level; a runtime Options toggle; sound; ASCII-art logo; per-theme animation overrides. diff --git a/openspec/changes/ui-animations/specs/pulldown-menus/spec.md b/openspec/changes/ui-animations/specs/pulldown-menus/spec.md new file mode 100644 index 0000000..8985c89 --- /dev/null +++ b/openspec/changes/ui-animations/specs/pulldown-menus/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Menu bar entrance sweep + +When animations are enabled (`ui-animations`), the system SHALL reveal the F9 menu bar left-to-right over 100 ms: columns of the bar row are painted from the left edge, uncovering the panels' top-border row progressively, with each menu title appearing as the sweep passes its position; the `Left` pull-down unfolds concurrently per `ui-animations`. From 100 ms the bar SHALL be identical to the non-animated bar. Closing the bar SHALL be instant. The bar SHALL be fully interactive from the first frame: hotkey letters and arrows act during the sweep exactly as afterwards. + +#### Scenario: Bar sweeps in + +- **WHEN** the user presses F9 and 50 ms have elapsed on the reducer clock +- **THEN** roughly the left half of the top row is the black-on-cyan bar showing `Left` and `Files`, the right half still shows the panels' top borders and the clock +- **AND** at 100 ms or later the full-width bar with all five titles is drawn identically to the pre-animation bar + +#### Scenario: Hotkey during the sweep + +- **WHEN** the bar is 30 ms into its sweep and the user presses the `Commands` hotkey letter +- **THEN** `Commands` becomes the active title and its pull-down opens, as it would after the sweep + +#### Scenario: Closing is instant + +- **WHEN** the bar is open and the user presses Esc with no pull-down showing +- **THEN** the bar is removed on the next frame with no reverse sweep diff --git a/openspec/changes/ui-animations/specs/startup-splash/spec.md b/openspec/changes/ui-animations/specs/startup-splash/spec.md new file mode 100644 index 0000000..ec09cad --- /dev/null +++ b/openspec/changes/ui-animations/specs/startup-splash/spec.md @@ -0,0 +1,60 @@ +## MODIFIED Requirements + +### Requirement: Frame-1 splash rendering + +The system SHALL render the startup splash as the very first painted frame from static identity data — a solid blue backdrop with a horizontally and vertically centered double-line box containing the product name, version, copyright, and tribute lines — so that first paint never waits on any I/O. When animations are enabled (`ui-animations`), frame 1 SHALL be the backdrop plus the first frame of the splash entrance animation and the complete box SHALL be reached within the minimum hold; when animations are disabled the complete box SHALL be frame 1. The identity lines (name, version, copyright, tribute) SHALL be defined in a single place in `core` and used verbatim; the version string SHALL be the crate version. The terminal cursor SHALL be hidden while the splash is shown, and all colors SHALL come from the `splash.*` theme roles. + +#### Scenario: Splash is the first frame + +- **WHEN** FileCommand launches at or above 80×24 with the splash enabled +- **THEN** the first painted frame is the splash: a solid blue backdrop with, when animations are disabled, the complete centered double-line box, or, when enabled, the opening frame of its entrance +- **AND** the completed box contains the product name, `Version `, the copyright line, and the tribute line +- **AND** it paints before the first directory listing has completed + +#### Scenario: Splash uses theme roles and hides the cursor + +- **WHEN** the splash renders under `nc-classic` +- **THEN** the frame is drawn in `splash.frame` (cyan on blue), the name in `splash.title` (bright-white), the version in `splash.version` (white), and the copyright/tribute in `splash.text` (cyan) +- **AND** the terminal cursor is hidden + +#### Scenario: Identity lines are a single source of truth + +- **WHEN** the splash renders its identity lines +- **THEN** they are read from the single `core` definition of the identity lines (name, version, copyright, tribute) rather than a splash-local copy + +#### Scenario: Mono theme rendering + +- **WHEN** the splash renders under `nc-mono` +- **THEN** it renders white on black + +## ADDED Requirements + +### Requirement: Splash entrance animation + +When animations are enabled, the system SHALL animate the splash entrance within the 800 ms minimum hold using only `splash.*` roles and the existing box glyphs: from 0 to 200 ms the double-line frame SHALL trace itself clockwise from the top-left corner; from 200 ms each identity line SHALL reveal outward from its center over 120 ms, starting 80 ms after the previous line, with newly revealed characters drawn in `splash.frame` for up to 66 ms before settling to the line's final role; from 560 to 760 ms a 3-cell window in `splash.version` SHALL sweep once left-to-right across the product name. From 760 ms the splash SHALL be identical to the non-animated splash. A key press at any point SHALL dismiss the splash entirely (not skip to its final frame) and be consumed, exactly as today; the minimum hold SHALL remain 800 ms. + +#### Scenario: Frame traces in + +- **WHEN** 100 ms have elapsed on the reducer clock since the splash started +- **THEN** roughly half the box perimeter is drawn, beginning at the top-left corner and proceeding clockwise, and no identity text is visible yet + +#### Scenario: Identity lines reveal outward + +- **WHEN** 400 ms have elapsed +- **THEN** the frame is complete, the product name and version lines are fully revealed, and the copyright line is partially revealed from its center outward + +#### Scenario: Title sweep then steady state + +- **WHEN** 650 ms have elapsed +- **THEN** a 3-cell `splash.version` window is positioned part-way across the product name +- **AND** at 760 ms or later the splash is identical to the pre-animation splash + +#### Scenario: Key mid-animation dismisses entirely + +- **WHEN** a key is pressed 150 ms after the splash started +- **THEN** the splash is dismissed and the panels appear (wiping in per `ui-animations`), the key is consumed, and no further splash frame is drawn + +#### Scenario: Disabled renders the finished splash + +- **WHEN** animations are disabled and the splash is shown +- **THEN** every splash frame is the complete box diff --git a/openspec/changes/ui-animations/specs/ui-animations/spec.md b/openspec/changes/ui-animations/specs/ui-animations/spec.md new file mode 100644 index 0000000..b759a42 --- /dev/null +++ b/openspec/changes/ui-animations/specs/ui-animations/spec.md @@ -0,0 +1,129 @@ +## ADDED Requirements + +### Requirement: Render-only motion model driven by the reducer clock + +The system SHALL implement every animation as a pure function of the elapsed time between the reducer's most recent `Tick` clock reading (`State::clock_ms`) and a timestamp the reducer recorded when the animated surface opened or changed. The reducer SHALL transition state instantly exactly as it does without animations and SHALL only additionally record these timestamps from `State::clock_ms`; it SHALL NOT read a clock, delay any transition, or lengthen any hold. Renderers SHALL interpolate from the recorded timestamp, and every key or mouse command SHALL apply to the final (non-animated) state immediately. Animations SHALL use only CP437-heritage glyphs and colors obtained from existing theme roles; no new theme role SHALL be introduced. + +#### Scenario: Input during an animation applies to the final state + +- **WHEN** a pull-down menu is 30 ms into its 100 ms unfold and the user presses Down +- **THEN** the selection moves to the next enabled item exactly as it would with animations disabled +- **AND** the pull-down continues unfolding around the already-moved highlight + +#### Scenario: Animation progress comes from the reducer clock, not a live clock + +- **WHEN** a state with an overlay stamped at `clock_ms = 1000` is rendered twice with `clock_ms = 1050` +- **THEN** both renders produce identical output +- **AND** no renderer or reducer reads a system clock to produce the frame + +#### Scenario: No new theme roles + +- **WHEN** a user theme file that defines exactly today's role set is loaded with animations enabled +- **THEN** it validates and every animation renders using only roles present in that file + +### Requirement: Animations are disabled by config or launch flag + +The system SHALL animate by default and SHALL render every surface in its final form immediately — identical to the pre-animation rendering — when `animations = false` is set in `config.toml` general options or the `--noanimations` launch flag is passed. When the flag and config disagree, `--noanimations` SHALL win. The `--noanimations` flag SHALL appear in the `--help` usage block's flag table. A `State` constructed without configuration (`State::empty`) SHALL have animations disabled so that existing snapshot and reducer tests are unaffected. + +#### Scenario: Default is animated + +- **WHEN** FileCommand launches with no `animations` key in config and no `--noanimations` flag +- **THEN** the splash, menus, and dialogs animate + +#### Scenario: Disabled via config + +- **WHEN** `animations = false` is set and the user presses F9 +- **THEN** the menu bar and `Left` pull-down render complete on the first frame, byte-identical to the pre-animation rendering + +#### Scenario: Flag overrides config + +- **WHEN** FileCommand launches with `--noanimations` while config has `animations = true` +- **THEN** no surface animates + +#### Scenario: Flag is documented in usage + +- **WHEN** the user runs `filecommand --help` +- **THEN** the flag table includes `--noanimations` + +#### Scenario: States built without config do not animate + +- **WHEN** a snapshot test renders a state created via `State::empty` with an open dialog +- **THEN** the dialog renders in its final form regardless of `clock_ms` + +### Requirement: Pop-up menus unfold and highlights settle + +The system SHALL open every pop-up menu — the F9 pull-down boxes, the F2 user menu, and the Enter file-action menu — by unfolding it top-down over 100 ms: the frame's top row appears first, item rows are added downward in order, and the bottom frame edge tracks the last revealed row, so the box is never taller than its final rectangle. A pull-down opened by Left/Right traversal or a hotkey jump SHALL unfold the same way while remaining open and interactive. Whenever the highlighted item changes, the newly highlighted row SHALL render in the `menu.hotkey` role for up to 50 ms and then settle to `menu.highlight`. The menu's final rendering, geometry, and content SHALL be unchanged from today. + +#### Scenario: Pull-down unfolds top-down + +- **WHEN** the user presses F9 and 50 ms have elapsed on the reducer clock +- **THEN** the `Left` pull-down shows its top frame row and roughly the first half of its item rows, closed by a bottom frame edge +- **AND** at 100 ms or later the pull-down is complete and identical to the pre-animation rendering + +#### Scenario: Traversal re-unfolds the adjacent pull-down + +- **WHEN** the `Files` pull-down is open and the user presses Right +- **THEN** the `Commands` pull-down begins unfolding immediately with no closed intermediate frame +- **AND** Down pressed during the unfold moves the `Commands` selection + +#### Scenario: Highlight settles after moving + +- **WHEN** the user presses Down in an open pull-down and the frame is rendered within 50 ms +- **THEN** the newly selected row renders in the `menu.hotkey` style +- **AND** a frame rendered 50 ms or later shows it in `menu.highlight` + +#### Scenario: User menu and file-action menu share the treatment + +- **WHEN** the user presses F2, or Enter on a file +- **THEN** the user menu or file-action menu unfolds top-down over 100 ms and its highlight settles exactly as the F9 pull-downs do + +### Requirement: Modal dialogs open with an iris + +The system SHALL open every modal dialog — operation input/confirmation/overwrite/error/progress/summary dialogs, Help and About, theme picker, drive select, find file, fuzzy jump, the quit confirmation, and the startup warning — by revealing the finished dialog through a centered rectangular window that grows from the dialog's center to its full rectangle over 100 ms. The dialog's state, final geometry, content, buttons, and hit regions SHALL be those of the finished dialog from the first frame, so clicks and keys during the iris behave exactly as when it is complete. A dialog that replaces another in the same slot (for example progress → overwrite conflict) SHALL iris again; the quit confirmation layered over another dialog SHALL iris without re-animating the dialog beneath it. + +#### Scenario: Dialog grows from its center + +- **WHEN** the user presses F7 and 50 ms have elapsed on the reducer clock +- **THEN** a centered window roughly half the dialog's width and height shows the central portion of the finished Make-directory dialog +- **AND** at 100 ms or later the dialog is complete and identical to the pre-animation rendering + +#### Scenario: Buttons work during the iris + +- **WHEN** the quit-confirmation dialog is 30 ms into its iris and the user presses Enter +- **THEN** the default button activates exactly as it would after the iris completed + +#### Scenario: Replacement dialog irises; layered quit dialog does not re-animate the one beneath + +- **WHEN** a running copy job raises an overwrite-conflict dialog in place of the progress dialog +- **THEN** the conflict dialog irises in +- **WHEN** the user then presses Esc over the panels with a dialog open and the quit confirmation opens over it +- **THEN** only the quit confirmation irises; the dialog beneath stays fully drawn + +### Requirement: Panels wipe in after the splash + +The system SHALL, when the splash is replaced by the panels (minimum hold elapsed or dismissing key), reveal the panel screen top-to-bottom over 150 ms, with unrevealed rows drawn in `screen.backdrop`. When the splash was skipped (`splash = false`, `--nosplash`, or below-floor placeholder), the panels SHALL be frame 1 in complete form with no wipe. + +#### Scenario: Panels wipe in after the hold + +- **WHEN** the splash hold elapses and 75 ms have passed on the reducer clock +- **THEN** roughly the top half of the panel screen is drawn and the rest is backdrop +- **AND** at 150 ms or later the screen is identical to the pre-animation panel rendering + +#### Scenario: No wipe when the splash is skipped + +- **WHEN** FileCommand launches with `--nosplash` +- **THEN** the first painted frame is the complete panel screen + +### Requirement: Loading feedback stays static + +The system SHALL NOT animate loading or progress feedback: streaming directory listings, the `Reading… N` mini-status, Info-panel `…` placeholders, drive-select volume-label fill-in, and the progress dialog's counters and byte gauge SHALL continue to render as static text updated in place. Decorative chrome (splash, menus, dialogs) is the only animated surface; this scoping supersedes design doc §4.10's blanket "never uses animation" statement for decorative chrome only. + +#### Scenario: Streaming listing has no spinner + +- **WHEN** a large directory is streaming into a panel with animations enabled +- **THEN** the mini-status shows `Reading… N` updated in place and no spinner, sweep, or glyph cycling appears anywhere in the panel + +#### Scenario: Progress gauge is not animated + +- **WHEN** a copy job's progress dialog is complete (iris finished) and bytes are transferring +- **THEN** the `█`/`░` gauge and counters change only when a progress event arrives diff --git a/openspec/changes/ui-animations/tasks.md b/openspec/changes/ui-animations/tasks.md new file mode 100644 index 0000000..40e61d1 --- /dev/null +++ b/openspec/changes/ui-animations/tasks.md @@ -0,0 +1,38 @@ +# Tasks: ui-animations + +## 1. Core motion model + +- [ ] 1.1 Add `filecommand-core::anim` with per-mille `progress`, `reveal_count`, `sweep_pos`, iris-rect helper, and the named duration constants from design D6 (ui-animations: "Render-only motion model driven by the reducer clock") +- [ ] 1.2 Add `State::animations`, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; `State::empty` sets `animations = false` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 1.3 Stamp `MenuState` (`opened_at_ms`, `pulldown_opened_at_ms`, `selected_at_ms`), `UserMenuState` and `FileActionMenuState` (`opened_at_ms`, `selected_at_ms`) from `state.clock_ms` in the reducer on open, traversal, and selection change (ui-animations: "Pop-up menus unfold and highlights settle") +- [ ] 1.4 Stamp `dialog_opened_at_ms` where each modal dialog becomes visible and `quit_opened_at_ms` where `quit_confirm` is set; stamp `panels_revealed_at_ms` on the Splash → Panels transition only (ui-animations: "Modal dialogs open with an iris"; "Panels wipe in after the splash") + +## 2. Configuration and launch flag + +- [ ] 2.1 Parse `animations = ` in `config::parse` (general options; default `true`) (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 2.2 Parse `--noanimations` in `parse_launch_args`; flag overrides config in `run`/`apply_config` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 2.3 Add `--noanimations` to `main.rs` `print_usage` and to the README flag table; add `animations` to the README config key table (ui-animations: "Animations are disabled by config or launch flag") + +## 3. Splash and panel reveal + +- [ ] 3.1 Animate `views/splash.rs`: clockwise frame trace, center-out identity-line reveal with `splash.frame` intermediate, `splash.version` title sweep; final frame identical to today's at ≥ 760 ms or when disabled (startup-splash: "Splash entrance animation"; "Frame-1 splash rendering") +- [ ] 3.2 Add the shared scratch-buffer reveal helper in `views/mod.rs` (iris window and top-rows wipe) (ui-animations: "Modal dialogs open with an iris") +- [ ] 3.3 Wipe the panels top-to-bottom over 150 ms when `panels_revealed_at_ms` is set (ui-animations: "Panels wipe in after the splash") + +## 4. Menus + +- [ ] 4.1 Menu bar left-to-right sweep in `views/menubar.rs` (pulldown-menus: "Menu bar entrance sweep") +- [ ] 4.2 Shared pop-up unfold + highlight-settle helper applied to F9 pull-downs, `views/user_menu.rs`, and `views/file_action_menu.rs` (ui-animations: "Pop-up menus unfold and highlights settle") + +## 5. Dialogs + +- [ ] 5.1 Wrap every modal dialog view in `render_phase`/`render` with the iris helper keyed on `dialog_opened_at_ms` (quit dialog on `quit_opened_at_ms`) (ui-animations: "Modal dialogs open with an iris") +- [ ] 5.2 Confirm hit-maps still use final geometry during the iris (ui-animations: "Modal dialogs open with an iris") + +## 6. Tests + +- [ ] 6.1 Unit tests for `anim` helpers (progress clamping, reveal counts at boundaries, iris rect never exceeds final rect) (ui-animations: "Render-only motion model driven by the reducer clock") +- [ ] 6.2 Reducer tests: stamps set from `clock_ms` on open/traversal/selection; key during splash still dismisses and is consumed; `panels_revealed_at_ms` is `None` when splash skipped (ui-animations; startup-splash: "Minimum hold and key dismissal") +- [ ] 6.3 Config/flag tests: default on, `animations = false`, `--noanimations` wins over `animations = true` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 6.4 `insta` snapshots with `animations = true` and pinned `clock_ms`: splash at 0 / 100 / 400 / 650 / 800 ms; pull-down at 0 / 50 / 100 ms; dialog iris at 0 / 50 / 100 ms; panel wipe at 75 ms; menu bar sweep at 50 ms (startup-splash; pulldown-menus; ui-animations) +- [ ] 6.5 Verify the full existing snapshot suite is byte-identical (animations off in `State::empty`) (ui-animations: "Animations are disabled by config or launch flag") From 28b1794342194073cadb85f4bd819010b565676b Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Thu, 8 Oct 2026 09:12:11 -0600 Subject: [PATCH 02/13] Add OpenSpec proposal reread-preserves-cursor After a confirmed delete (and every other same-directory re-read: copy/move/mkdir completion, Ctrl+R, stale-tab refresh) the panel jumps back to the top because Panel::begin_new_listing resets cursor and scroll offset unconditionally. This proposal adds a re-read anchor so the cursor returns to its entry or the nearest survivor (next after a delete, Norton Commander style) and the scroll offset is restored and re-clamped, applied while the listing streams so there is no transient jump. Modifies panel-navigation and file-operations. Co-Authored-By: Claude Fable 5.1 --- .../reread-preserves-cursor/.openspec.yaml | 2 + .../changes/reread-preserves-cursor/design.md | 66 +++++++++++++++ .../reread-preserves-cursor/proposal.md | 31 +++++++ .../specs/file-operations/spec.md | 25 ++++++ .../specs/panel-navigation/spec.md | 81 +++++++++++++++++++ .../changes/reread-preserves-cursor/tasks.md | 37 +++++++++ 6 files changed, 242 insertions(+) create mode 100644 openspec/changes/reread-preserves-cursor/.openspec.yaml create mode 100644 openspec/changes/reread-preserves-cursor/design.md create mode 100644 openspec/changes/reread-preserves-cursor/proposal.md create mode 100644 openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md create mode 100644 openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md create mode 100644 openspec/changes/reread-preserves-cursor/tasks.md diff --git a/openspec/changes/reread-preserves-cursor/.openspec.yaml b/openspec/changes/reread-preserves-cursor/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/reread-preserves-cursor/design.md b/openspec/changes/reread-preserves-cursor/design.md new file mode 100644 index 0000000..31d1565 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/design.md @@ -0,0 +1,66 @@ +# Design: reread-preserves-cursor + +## Context + +Every directory read in `filecommand-core` goes through `update::begin_listing` → `begin_listing_inner` → `Panel::begin_new_listing(cwd)` (`crates/filecommand-core/src/panel.rs`). That method resets `cursor = 0`, `scroll_offset = 0`, and `cursor_user_moved = false`, then the worker streams `Command::ListingChunk` events whose `insert_streamed` keeps the cursor pinned to 0 until the user moves (panel-navigation "Streamed listing keeps the top pinned until the user moves"). `Command::ListingComplete` clamps the cursor, reconciles the selection, consumes find-file's `pending_cursor_target` (if any) to settle the cursor on a named entry, and calls `reconcile_panel_viewport` to re-clamp the offset. + +That reset is right when the panel navigates into a different directory. It is wrong for the three callers that re-read the directory the panel is already showing, all of which reach `begin_listing_inner` today: + +- `Command::JobDone` — re-reads every active tab whose `cwd` matches the job's source or destination directory (file-operations "Automatic panel re-read on completion"). +- `Command::RereadPanel` — Ctrl+R and the Left/Right menu's Re-read item. +- Stale background-tab refresh on `SwitchTab` / `CloseTab` activation (panel-tabs "Stale background tab refresh on activation"). + +The user-visible symptom that prompted this change: confirm a delete partway down a long listing, and the panel jumps back to the top. + +## Goals / Non-Goals + +**Goals:** + +- No visual jump on any same-directory re-read: the cursor stays on its entry and the window stays where it was. +- Norton Commander landing rule after a delete: the entry that followed the deleted one; the one before it if the last entry was deleted; for a multi-selection delete, the first survivor at or after the cursor's old row. +- One mechanism shared by every re-read caller — no per-caller special cases. +- Core-only change, fully testable through `core::update` without a terminal. + +**Non-Goals:** + +- Tree display mode (its cursor and offset live in `TreeState` and it does not re-read through this path). +- Preserving the quick filter or the selection set across a re-read — `begin_new_listing` keeps clearing both; `ListingComplete`'s `reconcile_selection` is unchanged. +- Any change to navigation into a different directory, to find-file's deferred settle, or to the streaming-pinned-to-top rule for fresh directories. +- Brief-mode column geometry beyond reusing the existing `ensure_cursor_visible_brief` clamp. + +## Decisions + +### D1: Anchor by entry name with a survivor fallback order, not by numeric index + +Before a same-directory re-read, the panel captures a `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` — the previous entries' names in display order plus the cursor index and offset. When the new listing arrives, the cursor resolves to the first of these names that is present in the new listing, tried in this order: `names[cursor]`, then `names[cursor + 1..]` in order, then `names[..cursor]` in reverse. That yields "same entry if it survived, else the next survivor, else the nearest previous survivor" and covers a multi-selection delete (`{b, c, d}` deleted with the cursor on `c` lands on `e`) with no special casing. + +Alternative rejected — keep the numeric cursor index and clamp: after a multi-selection delete it lands on an arbitrary entry, and it is wrong whenever the re-read changes the ordering (a copy job adding entries above the cursor). + +### D2: Apply the anchor on every `ListingChunk`, consume it on `ListingComplete` + +A re-read streams; if the anchor were applied only on completion, the cursor would sit on row 0 for the frames between the first chunk and the final event. Instead `apply_listing_event` runs the anchor lookup after each chunk (cheap: a name scan over the entries in hand) and once more on completion, then clears it. When the anchor applies it sets `cursor_user_moved = true`, so `insert_streamed`'s pin-to-top stops competing with it. Until a chunk containing a resolvable name arrives, the cursor stays at 0 — unobservable in practice, since the anchored entry is usually in the first chunk. + +Find-file's `pending_cursor_target` is checked first on `ListingComplete` and wins if set; the two never coincide today because find-file navigates to a different directory, which clears the anchor (D4). + +### D3: Restore the scroll offset, then clamp to the new list + +On apply, `scroll_offset` is restored from the anchor and then clamped to `visible_len.saturating_sub(rows)` so a list that shrank at the bottom does not leave blank trailing rows (deleting the last visible entry at the end of a long listing pulls the window up by one, as Norton Commander does). The existing `reconcile_panel_viewport` then runs as it does for every other list mutation, so if the panel's body height differs from when the anchor was captured, the cursor-visibility clamp wins — the same rule panel-navigation already applies to tab restore. In Brief mode the restored offset is passed through `ensure_cursor_visible_brief`, which keeps it on a whole-column multiple. + +### D4: The anchor lives on `Panel` and is cleared by any different-directory navigation + +`Panel::reread_anchor: Option` sits beside `pending_cursor_target` and follows the same hygiene: `begin_new_listing(cwd)` clears it when `cwd != self.cwd`, so a navigation never inherits a stale anchor from an earlier re-read. It is included in the per-tab stash/restore data alongside `pending_cursor_target`, so a tab switched away mid-re-read keeps its anchor for when its listing completes. + +### D5: Capture in `begin_listing_inner`, so every caller benefits without plumbing + +`begin_listing_inner` is the single funnel for all three re-read callers and for ordinary navigation. It captures the anchor (via `Panel::capture_reread_anchor`) only when the target path equals the panel's current `cwd` **and** the current listing is `Complete`; a re-read issued while the previous listing is still streaming captures nothing and keeps today's behavior rather than anchoring to a half-loaded list. `JobDone`, `RereadPanel`, and the stale-tab path need no changes of their own. + +## Risks / Trade-offs + +- An O(n) snapshot of entry names per re-read — negligible against the directory read it accompanies, and freed on completion. +- A directory that changes on disk between capture and completion resolves to whatever survivor the fallback order finds; that is the intended "nearest surviving entry" behavior, not a failure mode. +- A re-read whose previous listing was still streaming keeps the old jump-to-top; accepted as the safer choice. + +## Open Questions + +- Should the quick filter survive a same-directory re-read? Today it is cleared; a follow-up proposal could keep it and re-apply it to the fresh listing. +- Should the selection set survive a copy-job re-read of the source panel? Today `begin_new_listing` clears it; separate concern. diff --git a/openspec/changes/reread-preserves-cursor/proposal.md b/openspec/changes/reread-preserves-cursor/proposal.md new file mode 100644 index 0000000..c3dbb41 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/proposal.md @@ -0,0 +1,31 @@ +# Change: reread-preserves-cursor + +## Why + +After the user confirms a delete (F8 or the Delete key), the panel re-reads its directory and the cursor and scroll position snap back to the top of the list — the user loses their place. Norton Commander keeps the cursor where it was, landing it on the entry that followed the deleted one. The same jump happens on every re-read of a directory the panel is already showing: copy/move/mkdir completion, Ctrl+R / menu Re-read, and a stale background tab's activation refresh. All of them funnel through `Panel::begin_new_listing`, which resets the cursor, the scroll offset, and the "user has moved" flag unconditionally, and the streamed listing then stays pinned to the first entry. + +## What Changes + +- A re-read of the directory a panel is **already displaying** preserves the user's position: the cursor returns to the same entry if it is still listed; otherwise to the first surviving entry at or after the cursor's previous row; otherwise to the nearest surviving entry before it. The scroll offset is restored and re-clamped so the window does not extend past the end of the shorter list and the cursor stays visible. +- This applies to every same-directory re-read: file-operation job completion (delete, copy, move, mkdir — including a cancellation after partial progress), Ctrl+R / the Left/Right menu's Re-read, and the stale-background-tab refresh on activation. When both panels browse the affected directory, both keep their own positions. +- The restored position holds **while the re-read streams in**, not only on completion, so the cursor never visibly flicks to the first row. +- Navigation into a **different** directory is unchanged: cursor and offset reset to the top and the streamed listing stays pinned until the user moves. +- Find-file's deferred cursor settle keeps precedence if both mechanisms are ever set on the same listing. +- Non-breaking. Tree mode, and preserving the quick filter or the selection set across a re-read, are out of scope (they keep today's behavior). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: adds the requirement "Same-directory re-read preserves cursor and scroll position" (cursor-anchor resolution order, scroll-offset restore and re-clamp, no transient jump while streaming, different-directory navigation unchanged); "Scroll offset is core panel state" gains the re-read anchor restore in its list of re-clamp triggers. +- `file-operations`: "Automatic panel re-read on completion" additionally states that each such re-read of a panel's current directory preserves that panel's cursor entry (or nearest survivor) and scroll offset, with a scenario for the post-delete case. + +## Impact + +- **Specs:** `panel-navigation` (one added requirement, one modified requirement), `file-operations` (one modified requirement, one added scenario). +- **Code:** `crates/filecommand-core/src/panel.rs` — a re-read anchor (previous entry names in display order, cursor index, scroll offset) stored on the panel, captured before a same-directory re-read and cleared by any navigation to a different directory, plus capture/apply helpers; `crates/filecommand-core/src/update.rs` — `begin_listing_inner` captures the anchor when the target path equals the panel's current directory, and `apply_listing_event` applies it on each `ListingChunk` and on `ListingComplete` (consuming it there). Tests in `crates/filecommand-core/src/update/tests.rs` and `panel.rs`. No `filecommand-tui` changes — the renderer already only reads `cursor` and `scroll_offset`. +- **Compatibility:** additive. The only observable change is that same-directory re-reads no longer reset the cursor and scroll offset. diff --git a/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md b/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md new file mode 100644 index 0000000..be31393 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Automatic panel re-read on completion + +When a job finishes — including a cancellation after partial progress — every tab, on either panel, whose directory matches an affected path SHALL be refreshed so its listing reflects the resulting file-system state without a manual refresh. A tab that is currently active on its panel SHALL re-read immediately, reusing the streaming listing path. A tab that is not currently active (a background tab — see `panel-tabs`) SHALL instead be marked stale and re-read automatically the moment it becomes active, rather than eagerly re-read while off-screen. Each such re-read of a panel's current directory SHALL preserve that panel's cursor entry (or its nearest survivor) and scroll offset as specified by `panel-navigation` — "Same-directory re-read preserves cursor and scroll position". + +#### Scenario: Panels refresh after a completed operation +- **WHEN** a copy/move/delete/mkdir job completes +- **THEN** the source and/or destination panels re-read and display the updated directory contents automatically + +#### Scenario: Panels refresh after a cancelled operation with partial changes +- **WHEN** a job is cancelled after it has already changed some files on disk +- **THEN** the affected panels still re-read automatically so the listing reflects the partial result + +#### Scenario: The opposite panel sharing the affected directory also refreshes +- **WHEN** both panels are browsing the same directory and a delete job completes in the active panel +- **THEN** the opposite (inactive) panel also re-reads automatically and no longer shows the deleted entry + +#### Scenario: A background tab on the affected directory is marked stale, not eagerly re-read +- **WHEN** a panel has a background tab (not the active tab) browsing a directory affected by a completed job +- **THEN** that background tab is marked stale rather than re-read immediately, and its cached listing is left untouched until it becomes active + +#### Scenario: Post-delete re-read keeps the user's place +- **WHEN** a delete job completes in a panel scrolled partway down a long listing +- **THEN** the panel re-reads, the deleted entry is gone, the window stays where it was, and the cursor is on the entry that followed the deleted one diff --git a/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md b/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md new file mode 100644 index 0000000..30f7545 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Same-directory re-read preserves cursor and scroll position + +When a panel re-reads the directory it is already displaying — after a file-operation job completes or is cancelled, on Ctrl+R or the Left/Right menu's Re-read item, or on a stale background tab's activation refresh — the system SHALL restore the cursor to the entry it was on if that entry is still listed; otherwise to the first surviving entry at or after the cursor's previous row; otherwise to the nearest surviving entry before it. The scroll offset SHALL be restored to its previous value and then re-clamped so the window does not extend past the end of the new list and the cursor remains visible. The restored position SHALL hold while the re-read streams in — the cursor SHALL NOT transiently jump to the first entry — and after completion. Navigation into a different directory SHALL continue to reset the cursor and offset to the top. For same-directory re-reads this requirement takes precedence over "Streamed listing keeps the top pinned until the user moves". + +#### Scenario: Deleting the cursored file lands on the next entry + +- **WHEN** the cursor is on `b.txt` in a listing `a.txt`, `b.txt`, `c.txt` and the user confirms a delete of `b.txt` that completes +- **THEN** after the automatic re-read the cursor is on `c.txt` and the scroll offset is unchanged + +#### Scenario: Deleting the last entry lands on the previous one + +- **WHEN** the cursor is on the last entry of a listing and the user confirms a delete of that entry that completes +- **THEN** after the re-read the cursor is on the entry that preceded it, now the last entry + +#### Scenario: Multi-selection delete lands on the first survivor at or after the cursor + +- **WHEN** a listing `a`, `b`, `c`, `d`, `e` has `b`, `c`, `d` selected with the cursor on `c`, and a delete of the selection completes +- **THEN** after the re-read the cursor is on `e` + +#### Scenario: Scrolled window stays put + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 30 and the cursor on position 40, and a delete of the cursored entry completes +- **THEN** after the re-read the scroll offset is still 30 and the cursor is on position 40, which is the entry formerly at position 41 + +#### Scenario: Deleting at the very bottom pulls the window up by one + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 40 and the cursor on the last entry, and a delete of that entry completes +- **THEN** after the re-read the list has 59 entries, the scroll offset is re-clamped to 39, the cursor is on the new last entry, and no blank trailing row is shown + +#### Scenario: Ctrl+R keeps the cursor's entry + +- **WHEN** the cursor is on `report.txt` partway down a listing and the user presses Ctrl+R +- **THEN** after the re-read the cursor is on `report.txt` and the scroll offset is unchanged + +#### Scenario: No jump while the re-read streams + +- **WHEN** a same-directory re-read is in progress and its first streamed chunk contains the anchored entry +- **THEN** the cursor is on that entry as soon as the chunk is applied, not on the first entry + +#### Scenario: Navigating to a different directory still starts at the top + +- **WHEN** the cursor is partway down a listing and the user presses Enter on a subdirectory +- **THEN** the new listing starts with the cursor on its first entry and the scroll offset at 0 + +## MODIFIED Requirements + +### Requirement: Scroll offset is core panel state + +Each panel's scroll offset SHALL live in `filecommand-core` panel state (with Tree mode's offset in its tree state), and all offset changes SHALL flow through `core::update` — the renderer only reads it. The offset SHALL be a position in the quick-filter-narrowed visible list, not a raw entry index. Core SHALL derive each panel's body row count from state it already holds (terminal size from `Resize`, the panel split, the panel's display mode, and tab-strip visibility) — it SHALL NOT query the terminal — and SHALL re-clamp the offset so the cursor stays visible after every mutation that can move the cursor or change the list: cursor movement, quick-filter edits, re-sort, streamed listing updates, directory load completion (including find-file's deferred cursor settle and the same-directory re-read anchor restore), tab restore, and terminal resize. + +#### Scenario: Quick-filter narrowing re-clamps the offset + +- **WHEN** a quick-filter keystroke narrows the visible list so the current offset would leave the cursor's entry outside the window +- **THEN** the offset is re-clamped in the same reducer step so the cursor's entry is visible + +#### Scenario: Re-sort keeps the cursor's entry in view + +- **WHEN** the sort mode changes and the cursor re-anchors to the same entry at a new position +- **THEN** the offset is re-clamped so that entry is inside the window + +#### Scenario: Terminal resize re-clamps + +- **WHEN** the terminal shrinks so the current offset would leave the cursor below the new, shorter window +- **THEN** the next reducer step after the `Resize` re-clamps the offset and the cursor is visible in the new geometry + +#### Scenario: Tab restore re-clamps against the current viewport + +- **WHEN** a tab stashed with a scroll offset is restored while the panel's body height differs from when it was stashed +- **THEN** the restored offset is re-clamped so the restored cursor is visible at the current height + +#### Scenario: Streamed listing keeps the top pinned until the user moves + +- **WHEN** entries stream into a freshly loaded directory and the user has not moved the cursor +- **THEN** the offset stays 0 with the cursor pinned to the first entry, exactly as the cursor itself already behaves + +#### Scenario: Re-read anchor restore re-clamps against the shorter list + +- **WHEN** a same-directory re-read restores a scroll offset that would leave blank rows below the end of the now-shorter list +- **THEN** the offset is re-clamped in the same reducer step so the window ends at the last entry and the cursor remains visible diff --git a/openspec/changes/reread-preserves-cursor/tasks.md b/openspec/changes/reread-preserves-cursor/tasks.md new file mode 100644 index 0000000..702ce59 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/tasks.md @@ -0,0 +1,37 @@ +# Tasks: reread-preserves-cursor + +## 1. Core panel state (`crates/filecommand-core/src/panel.rs`) + +- [ ] 1.1 Add `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` and `Panel::reread_anchor: Option`, initialized to `None` (design D1, D4) +- [ ] 1.2 Include `reread_anchor` in the per-tab stash/restore data beside `pending_cursor_target` so a tab switched away mid-re-read keeps it (design D4) +- [ ] 1.3 Add `Panel::capture_reread_anchor()` — records entry names in display order, cursor, and scroll offset only when `progress` is `Complete`; otherwise leaves the anchor `None` (design D5) +- [ ] 1.4 Add `Panel::apply_reread_anchor(rows)` — resolves the cursor in the order `names[cursor]`, `names[cursor + 1..]`, `names[..cursor]` reversed; on a hit sets `cursor` and `cursor_user_moved = true`, restores `scroll_offset` and clamps it to `visible_len.saturating_sub(rows)`; returns whether a name resolved (panel-navigation: "Same-directory re-read preserves cursor and scroll position"; design D1, D3) +- [ ] 1.5 In `begin_new_listing(cwd)`, clear `reread_anchor` when `cwd != self.cwd` so a navigation never inherits a stale anchor (design D4) + +## 2. Reducer (`crates/filecommand-core/src/update.rs`) + +- [ ] 2.1 In `begin_listing_inner`, call `capture_reread_anchor()` before `begin_new_listing` when `path == panel.cwd` (design D5) +- [ ] 2.2 In `apply_listing_event`, on `ListingChunk` call `apply_reread_anchor(rows)` after the chunk is inserted and before `reconcile_panel_viewport` (panel-navigation: "No jump while the re-read streams"; design D2) +- [ ] 2.3 In `apply_listing_event`, on `ListingComplete` apply `pending_cursor_target` first (unchanged), then `apply_reread_anchor(rows)` if no find-file target was consumed, then take the anchor so it is consumed, then `reconcile_panel_viewport` (design D2) +- [ ] 2.4 Route the Brief-mode offset restore through `ensure_cursor_visible_brief` so the restored offset stays on a whole-column multiple (design D3) + +## 3. Tests + +- [ ] 3.1 `update/tests.rs`: `JobDone` delete of the cursored entry lands the cursor on the following entry with the offset unchanged (panel-navigation: "Deleting the cursored file lands on the next entry"; file-operations: "Post-delete re-read keeps the user's place") +- [ ] 3.2 `update/tests.rs`: deleting the last entry lands on the new last entry (panel-navigation: "Deleting the last entry lands on the previous one") +- [ ] 3.3 `update/tests.rs`: multi-selection delete `{b, c, d}` with the cursor on `c` lands on `e` (panel-navigation: "Multi-selection delete lands on the first survivor at or after the cursor") +- [ ] 3.4 `update/tests.rs`: 60 entries, 20-row body, offset 30, cursor at position 40; delete the cursored entry → offset 30, cursor at position 40 (panel-navigation: "Scrolled window stays put") +- [ ] 3.5 `update/tests.rs`: offset 40, cursor on the last of 60; delete it → 59 entries, offset 39, cursor on the last entry (panel-navigation: "Deleting at the very bottom pulls the window up by one"; "Re-read anchor restore re-clamps against the shorter list") +- [ ] 3.6 `update/tests.rs`: `RereadPanel` keeps the cursor on `report.txt` with the offset unchanged (panel-navigation: "Ctrl+R keeps the cursor's entry") +- [ ] 3.7 `update/tests.rs`: the first `ListingChunk` of a same-directory re-read containing the anchored name already places the cursor on it (panel-navigation: "No jump while the re-read streams") +- [ ] 3.8 `update/tests.rs`: Enter on a subdirectory after a re-read starts the new listing at cursor 0 / offset 0 (panel-navigation: "Navigating to a different directory still starts at the top") +- [ ] 3.9 `update/tests.rs`: extend `job_done_delete_rereads_both_panels_when_both_browse_the_same_directory` to assert the inactive panel's cursor entry and offset are also preserved (file-operations: "The opposite panel sharing the affected directory also refreshes") +- [ ] 3.10 `update/tests.rs`: a re-read issued while the previous listing is still `Streaming` captures no anchor and behaves as before (design D5) +- [ ] 3.11 `panel.rs`: unit tests for `apply_reread_anchor`'s resolution order and for the Brief-mode column-aligned offset restore (design D1, D3) +- [ ] 3.12 Confirm the existing `job_done_*`, `reread_*`, and find-file settle tests still pass unchanged + +## 4. Verify + +- [ ] 4.1 `cargo test --workspace` green +- [ ] 4.2 `openspec validate reread-preserves-cursor --strict` clean +- [ ] 4.3 Manual: in a directory longer than the panel, scroll partway down, F8 a file → no jump, cursor on the following entry; Ctrl+R → no jump; Enter into a subdirectory → listing starts at the top From 9f7669ca1cd322aa4fa3f362d1affa491b8af8e0 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:17 -0600 Subject: [PATCH 03/13] Add OpenSpec proposal config-profile-directory Resolve one per-user profile directory (--config-dir flag, portable marker, or %APPDATA%\FileCommand) for config.toml, history.json, and usermenu.toml instead of the launch directory; create it on first run, fail soft with a startup warning, and show a legacy notice for old files in the working directory without migrating them. Co-Authored-By: Claude Fable 5.1 --- .../config-profile-directory/.openspec.yaml | 2 + .../config-profile-directory/design.md | 50 +++++++++++++++++++ .../config-profile-directory/proposal.md | 32 ++++++++++++ .../specs/application-shell/spec.md | 33 ++++++++++++ .../changes/config-profile-directory/tasks.md | 35 +++++++++++++ 5 files changed, 152 insertions(+) create mode 100644 openspec/changes/config-profile-directory/.openspec.yaml create mode 100644 openspec/changes/config-profile-directory/design.md create mode 100644 openspec/changes/config-profile-directory/proposal.md create mode 100644 openspec/changes/config-profile-directory/specs/application-shell/spec.md create mode 100644 openspec/changes/config-profile-directory/tasks.md diff --git a/openspec/changes/config-profile-directory/.openspec.yaml b/openspec/changes/config-profile-directory/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/config-profile-directory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/config-profile-directory/design.md b/openspec/changes/config-profile-directory/design.md new file mode 100644 index 0000000..d7c53ff --- /dev/null +++ b/openspec/changes/config-profile-directory/design.md @@ -0,0 +1,50 @@ +# Design: config-profile-directory + +## Context + +`app.rs:121` calls `config::load(Path::new("config.toml"))`; `app.rs:150-158` loads `history.json` and `usermenu.toml` the same way and stores the bare names in `Runtime` for atomic saves (`save_theme_atomic`, `save_panel_split_atomic`, `save_history_file_atomic`). `config::load_user_menu` creates `usermenu.toml` with defaults when absent (user-menu "Create and recover the usermenu.toml file"). Nothing reads a `themes/` directory. The design doc §3.1 names `%APPDATA%\FileCommand`. + +## Goals / Non-Goals + +**Goals:** + +- One stable per-user location for all three files. +- A portable escape hatch and an explicit override for testing/scripting. +- No silent data movement. +- Discoverable: usage text and the warning dialog name the location. + +**Non-Goals:** + +- Automatic migration of old files. +- A config-dir entry in the Info panel or About dialog. +- Loading user theme files (separate proposal, if ever). +- Changing any file format. + +## Decisions + +### D1: Resolution order is flag → portable marker → platform default + +`--config-dir ` (also `--config-dir=`) wins outright and may be relative (resolved against the cwd at startup). Otherwise, if `/portable` exists (any file, contents ignored), the profile is ``. Otherwise `%APPDATA%\FileCommand` via `std::env::var_os("APPDATA")`; on non-Windows `$XDG_CONFIG_HOME/filecommand` else `$HOME/.config/filecommand`. No new crate: `std::env` suffices. If none of the environment variables resolve, fall back to the cwd with a startup warning (last resort, never silent). + +Alternative rejected: an environment-variable override — the flag covers scripting and avoids a second mechanism. + +### D2: Create on demand; fail soft + +`create_dir_all(profile)` before the first load. On failure the loaders still run (returning defaults for missing files), `Runtime` records `persist_enabled = false` so the three save functions are skipped, and `startup_warning` says `Could not create : — settings will not be saved this session`. + +### D3: Legacy notice is a warning, not a migration + +Only when the profile has no `config.toml` **and** the cwd has at least one of the three files: `startup_warning = "Found in ; FileCommand now reads . Move them there to keep your settings."`. The check is a pure function in `config.rs` over two `&Path`s so it is unit-tested with temp dirs. Precedence with other startup warnings follows the existing single-field behavior (last writer wins), unchanged. + +### D4: Usage text prints the resolved default + +`print_usage` adds `--config-dir Use for config.toml, history.json, usermenu.toml` and a trailing line `Default profile directory: ` so a user can find their files without reading docs. + +## Risks / Trade-offs + +- [Users with tuned `config.toml` files in project directories see the notice on every launch from such a directory] → The notice is explicit and names both paths; the alternative (silent cwd behavior) is the bug being fixed. +- [A roaming `%APPDATA%` profile means `history.json` churn roams] → It is a few KB; matches the design doc. + +## Open Questions + +- None. diff --git a/openspec/changes/config-profile-directory/proposal.md b/openspec/changes/config-profile-directory/proposal.md new file mode 100644 index 0000000..834e4af --- /dev/null +++ b/openspec/changes/config-profile-directory/proposal.md @@ -0,0 +1,32 @@ +# Change: config-profile-directory + +## Why + +FileCommand resolves `config.toml`, `history.json`, and `usermenu.toml` as bare relative paths (`app.rs` `CONFIG_FILE`, `config::HISTORY_FILE`, `config::USERMENU_FILE`), so they land in whatever directory the process was launched from. Every `cd somewhere; filecommand` creates three files there, and command history and fuzzy-jump frecency fragment per launch directory, so Ctrl+J never learns the user's habits across launches. The design doc (§3.1) and the `fuzzy-jump` spec ("persist that history in `history.json` in the platform config directory") both already call for `%APPDATA%\FileCommand`; the code never implemented it and the README documents the cwd behavior as if intended. + +## What Changes + +- A single **profile directory** is resolved once at startup and used for every configuration file: `config.toml`, `history.json`, `usermenu.toml`. +- Resolution order: `--config-dir ` launch flag → portable mode (a file named `portable` beside the executable → the executable's directory) → platform default (`%APPDATA%\FileCommand` on Windows; `$XDG_CONFIG_HOME/filecommand` or `~/.config/filecommand` elsewhere). +- The directory is created on first run. If it cannot be created or written, the session runs on in-memory defaults, persistence is skipped, and the existing dismissable startup-warning dialog names the path and error. +- **Legacy notice, no silent migration**: when the profile directory has no `config.toml` and the launch directory contains `config.toml`, `history.json`, or `usermenu.toml`, the startup warning tells the user where FileCommand now reads from and that the old files can be moved. Nothing is copied or deleted. +- `--help` usage gains `--config-dir ` and prints the resolved default profile directory. +- README: the Configuration section is rewritten for the profile directory; the documented `themes/*.toml` directory is dropped from the README because no code loads user theme files (theme-selection lists built-ins only). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: adds the requirement "Configuration profile directory" (resolution order, creation, write-failure fallback, legacy notice, usage output). + +## Impact + +- `crates/filecommand-tui/src/main.rs` — `parse_launch_args` gains `--config-dir`; `print_usage` gains the flag and the resolved default directory line. +- `crates/filecommand-tui/src/app.rs` — a `resolve_profile_dir` resolver (flag → portable marker → platform default) replaces the three bare file names; `Runtime.history_path`/`config_path` and the user-menu path become `profile_dir.join(..)`; `create_dir_all` with warning fallback; legacy-files check feeds `state.startup_warning`. +- `crates/filecommand-core/src/config.rs` — unchanged API (all loaders/savers already take a `&Path`); new pure helper `legacy_files_present(cwd, profile)` for testability. +- README Configuration section; `installer/README.md` note that per-user settings live under `%APPDATA%\FileCommand` (the installer itself ships no config files). +- Non-breaking for code; **behavioral change for users** who relied on per-directory config files (the legacy notice covers it). diff --git a/openspec/changes/config-profile-directory/specs/application-shell/spec.md b/openspec/changes/config-profile-directory/specs/application-shell/spec.md new file mode 100644 index 0000000..8f1bd7f --- /dev/null +++ b/openspec/changes/config-profile-directory/specs/application-shell/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Configuration profile directory + +The system SHALL resolve one profile directory at startup and SHALL read and write `config.toml`, `history.json`, and `usermenu.toml` only there, never relative to the process working directory. Resolution order SHALL be: the `--config-dir ` launch flag; else, when a file named `portable` exists beside the executable, the executable's directory; else the platform default — `%APPDATA%\FileCommand` on Windows and `$XDG_CONFIG_HOME/filecommand` (falling back to `~/.config/filecommand`) elsewhere. The directory SHALL be created on first run. If it cannot be created or written, the session SHALL run on in-memory defaults, SHALL skip persistence, and SHALL raise the dismissable startup-warning dialog naming the path and the error. When the resolved profile holds no `config.toml` and the working directory contains any of the three files, the startup-warning dialog SHALL state where FileCommand now reads its files and that the old files may be moved; the system SHALL NOT copy, move, or delete them. The `--help` usage text SHALL list `--config-dir` and SHALL print the resolved default profile directory. + +#### Scenario: Platform default is used when nothing overrides it +- **WHEN** FileCommand starts on Windows from `C:\Projects\app` with no `--config-dir` flag and no `portable` marker beside the executable +- **THEN** it reads and writes `config.toml`, `history.json`, and `usermenu.toml` under `%APPDATA%\FileCommand`, and no file is created in `C:\Projects\app` + +#### Scenario: Explicit --config-dir override +- **WHEN** FileCommand is launched with `--config-dir D:\fc-test` +- **THEN** every configuration file is read from and written to `D:\fc-test`, and the portable marker and platform default are ignored + +#### Scenario: Portable mode via marker file +- **WHEN** a file named `portable` exists in the executable's directory and no `--config-dir` flag is given +- **THEN** the executable's directory is the profile directory + +#### Scenario: History and frecency are shared across launch directories +- **WHEN** the user visits directories in a session launched from `C:\a`, exits, and relaunches from `C:\b` +- **THEN** the Ctrl+J fuzzy-jump list and command history include the entries recorded in the first session + +#### Scenario: Unwritable profile falls back without losing the session +- **WHEN** the profile directory cannot be created +- **THEN** the application starts with default settings, the startup-warning dialog names the path and error, and theme/split/history changes made during the session are not written to disk + +#### Scenario: Legacy files in the working directory produce a notice, not a migration +- **WHEN** the profile directory has no `config.toml` and the working directory contains `config.toml` and `history.json` +- **THEN** the startup-warning dialog names those files, the working directory, and the profile directory, and both files are left exactly as they were + +#### Scenario: Usage names the profile directory +- **WHEN** `filecommand --help` is run +- **THEN** the usage text lists `--config-dir ` and ends with the resolved default profile directory path diff --git a/openspec/changes/config-profile-directory/tasks.md b/openspec/changes/config-profile-directory/tasks.md new file mode 100644 index 0000000..b8a41a2 --- /dev/null +++ b/openspec/changes/config-profile-directory/tasks.md @@ -0,0 +1,35 @@ +# Tasks: config-profile-directory + +## 1. Launch arguments (`crates/filecommand-tui/src/main.rs`) + +- [ ] 1.1 `parse_launch_args`: accept `--config-dir ` and `--config-dir=`; a missing value is a startup warning, not a crash (application-shell "Configuration profile directory": "Explicit --config-dir override") +- [ ] 1.2 `print_usage`: add the flag line and `Default profile directory: ` (scenario "Usage names the profile directory"; design D4) + +## 2. Profile resolution (`crates/filecommand-tui/src/app.rs`) + +- [ ] 2.1 Add `resolve_profile_dir(flag: Option, exe_dir: Option<&Path>) -> (PathBuf, Option)` implementing D1 (returns an optional warning for the last-resort cwd fallback) +- [ ] 2.2 `create_dir_all`; on failure set `persist_enabled = false` and the D2 warning (scenario "Unwritable profile falls back without losing the session") +- [ ] 2.3 Replace the bare `CONFIG_FILE`/`HISTORY_FILE`/`USERMENU_FILE` paths with `profile.join(..)` for loading and for `Runtime.config_path`/`history_path`/the user-menu path (scenarios "Platform default is used when nothing overrides it", "History and frecency are shared across launch directories") +- [ ] 2.4 Skip `PersistTheme`/`PersistPanelSplit`/`PersistHistory` effects when `persist_enabled` is false + +## 3. Legacy notice (`crates/filecommand-core/src/config.rs`) + +- [ ] 3.1 Add pure `legacy_files_present(cwd: &Path, profile: &Path) -> Vec<&'static str>` (empty when the profile already has `config.toml`) (design D3) +- [ ] 3.2 `app.rs` turns a non-empty result into the D3 `startup_warning` (scenario "Legacy files in the working directory produce a notice, not a migration") + +## 4. Tests + +- [ ] 4.1 `main.rs`: `--config-dir` parsing (both forms; missing value) +- [ ] 4.2 `app.rs`: `resolve_profile_dir` — flag wins; portable marker wins over the platform default; platform default from `APPDATA` (cfg(windows)) / XDG (cfg(not(windows))) +- [ ] 4.3 `config.rs`: `legacy_files_present` with temp dirs — each file individually, none, and profile-already-populated +- [ ] 4.4 Existing config/history/user-menu loader tests pass unchanged (they already take explicit paths) + +## 5. Docs + +- [ ] 5.1 README: rewrite the "Configuration" intro for the profile directory, add `--config-dir` to the flag table, document portable mode and the legacy notice; remove the `themes/*.toml` subsection (no loader exists) and the `themes/` mention in the `theme` key row +- [ ] 5.2 `installer/README.md`: one line stating where per-user settings live + +## 6. Verify + +- [ ] 6.1 `cargo test --workspace` green; `openspec validate config-profile-directory --strict` clean +- [ ] 6.2 Manual: launch from two different directories, change the theme in one, relaunch from the other → theme persists; launch from a directory holding an old `config.toml` → notice shown; `filecommand --help` prints the default path From 9b4c69fb2f38afb1990534ba282a5f7a30c3c527 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:17 -0600 Subject: [PATCH 04/13] Add OpenSpec proposal full-mode-column-widths Render Full-mode Size, Date, and Time values untruncated (9/8/5 cells, sizes right-aligned) by spending the row's slack cells; reserved widths and the column-drop ladder are unchanged so the 80x24 four-column guarantee holds. Co-Authored-By: Claude Fable 5.1 --- .../full-mode-column-widths/.openspec.yaml | 2 + .../changes/full-mode-column-widths/design.md | 42 ++++++++++++ .../full-mode-column-widths/proposal.md | 30 +++++++++ .../specs/panel-navigation/spec.md | 64 +++++++++++++++++++ .../changes/full-mode-column-widths/tasks.md | 23 +++++++ 5 files changed, 161 insertions(+) create mode 100644 openspec/changes/full-mode-column-widths/.openspec.yaml create mode 100644 openspec/changes/full-mode-column-widths/design.md create mode 100644 openspec/changes/full-mode-column-widths/proposal.md create mode 100644 openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md create mode 100644 openspec/changes/full-mode-column-widths/tasks.md diff --git a/openspec/changes/full-mode-column-widths/.openspec.yaml b/openspec/changes/full-mode-column-widths/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/full-mode-column-widths/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/full-mode-column-widths/design.md b/openspec/changes/full-mode-column-widths/design.md new file mode 100644 index 0000000..97e2847 --- /dev/null +++ b/openspec/changes/full-mode-column-widths/design.md @@ -0,0 +1,42 @@ +# Design: full-mode-column-widths + +## Context + +`panel.rs:555-560`: `interior_w = w - 2`; `reserved_w() = SIZE(9)+DATE(8)+TIME(5)+3 = 25`; `name_w = interior_w - marker_w - 25`. `format_full_row` emits `pad(name,name_w) │ ' ' pad(size,7) ' ' pad(date,7) ' ' pad(time,4)` = `name_w + 22` cells, then the header/row is padded to `interior_w` — three cells wasted. At 80×24 with a 50/50 split, `w = 40`, `interior_w = 38`, `name_w = 13` (12 with the git marker column). + +## Goals / Non-Goals + +**Goals:** + +- Every Size/Date/Time value legible in full at every width where its column renders. +- Keep the ladder, `MIN_NAME_W = 12`, and the 80×24 four-column guarantee. +- NC-style right-aligned sizes. + +**Non-Goals:** + +- Changing date/time formats, Brief mode, the mini-status, the git marker column, or scrollbar geometry. + +## Decisions + +### D1: Spend the slack, keep `reserved_w = 25` + +New row: `pad(name, name_w)` + `│` + `rpad(size, 9)` + ` ` + `pad(date, 8)` + ` ` + `pad(time, 5)` = `name_w + 25` = exactly the interior. `ColumnSet::reserved_w` is **not** changed: it keeps returning today's 25 / 20 / 12 / 0 (`SIZE 9 + DATE 8 + TIME 5 + 3`, dropping Time then Date), so `choose_columns`, every breakpoint, and its tests hold. Only `format_full_row` changes: the text widths become `SIZE_TEXT_W = 9`, `DATE_TEXT_W = 8`, `TIME_TEXT_W = 5` and the space after `│` goes away. For the reduced sets the row is shorter than the reserved width (Name+Size+Date: `name_w + 19` of 20; Name+Size: `name_w + 10` of 12) and the remaining cells stay trailing padding, exactly as the slack is handled today. + +Alternative rejected: widen the columns and let the ladder drop Time at 80×24 — it would break the nominal-size guarantee for no gain, since the slack already covers the content. + +### D2: Right-align numeric sizes; directory markers fill the column + +`format_size` yields 4 cells in practice (`1023`, `999G`) and at most 5 (`1024K` after rounding), well within 9; right-aligned under a left-aligned `Size` header reads as it did in NC 5.5. `▶SUB-DIR◀`/`▶UP--DIR◀` are 9 cells and fill the column. Header labels (`Name↓`, `Size`, `Date`, `Time`) stay left-aligned in their cells — the sort arrow logic is untouched. + +### D3: Snapshots are regenerated, not hand-edited + +`cargo insta test --accept` after the change, then review the diff: every Full-mode row should change only in the Size/Date/Time cells and never in the Name cell or the frame. + +## Risks / Trade-offs + +- [Wide snapshot churn in one commit] → Mechanical and reviewable with the D3 rule. +- [Dropping the space after `│` makes `│▶SUB-DIR◀` adjacent] → That is the NC look and it keeps `name_w` intact at 80 columns. + +## Open Questions + +- None. diff --git a/openspec/changes/full-mode-column-widths/proposal.md b/openspec/changes/full-mode-column-widths/proposal.md new file mode 100644 index 0000000..5e34217 --- /dev/null +++ b/openspec/changes/full-mode-column-widths/proposal.md @@ -0,0 +1,30 @@ +# Change: full-mode-column-widths + +## Why + +Full mode renders every Size, Date, and Time value truncated: the renderer reserves 9/8/5 cells per column but pads the text into 7/7/4 (`views/panel.rs` `format_full_row`: `SIZE_COL_W - 2`, `DATE_COL_W - 1`, `TIME_COL_W - 1`), while the values are 9 (`▶SUB-DIR◀`), 8 (`MM-DD-YY`), and 5 (`HH:MM`) cells wide. The README's own screenshots show `▶SUB-DI`, `06-27-2`, `23:3`. The year digit and the minute digit are always cut off, so the user cannot tell 2025 from 2026 or 23:30 from 23:39 without reading the mini-status. Meanwhile each row leaves three interior cells blank at its right edge. + +## What Changes + +- Full-mode rows and the header lay out as `Name│Size Date Time` with the value widths equal to their content: Size 9, Date 8, Time 5, single-space separators after Size and Date, no space after the `│`. Reserved width stays 25, so the Name-column ladder, its 12-cell minimum, and the "all four columns at 80×24" guarantee are unchanged, and the row now fills the interior exactly. +- Numeric sizes are right-aligned within the 9-cell Size column (Norton Commander convention); `▶SUB-DIR◀` and `▶UP--DIR◀` fill it. Header labels keep their current left alignment and sort arrows. +- `MM-DD-YY` and `HH:MM` render in full. +- Brief mode, the mini-status ladder, and the column-drop ladder are untouched. Snapshot tests that contain Full-mode rows are re-accepted. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: "Full display mode layout" gains the exact column widths and the no-truncation guarantee; "Entry row rendering" states that `▶SUB-DIR◀` renders complete and sizes are right-aligned. + +## Impact + +- `crates/filecommand-tui/src/views/panel.rs` — `format_full_row` and the three text-width constants; `choose_columns`/`reserved_w` are untouched (25 / 20 / 12 / 0). +- **Overlap:** `hidden-entry-styling` also carries a MODIFIED "Entry row rendering" delta; whichever lands second re-bases its text on the other's merged requirement. +- `crates/filecommand-tui/tests/snapshot_views.rs` snapshots containing Full-mode rows (about half of the 100 `.snap` files) are regenerated and reviewed. +- README screenshots become stale (text only; optional refresh). +- Non-breaking. diff --git a/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md b/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md new file mode 100644 index 0000000..a5cd82f --- /dev/null +++ b/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md @@ -0,0 +1,64 @@ +## MODIFIED Requirements + +### Requirement: Full display mode layout + +The system SHALL render each panel in Full display mode with a double-line border, the current directory path centered in the top border, a column header row, entry rows, and a mini-status line inside the bottom border. The columns SHALL be the widest set from the ladder `Name+Size+Date+Time → Name+Size+Date → Name+Size → Name` that keeps the Name column at least 12 display cells wide, dropping columns rightmost-first (Time, then Date, then Size) as the panel narrows and restoring them in reverse as it widens; the column header row SHALL show exactly the columns currently rendered. A rendered row SHALL be laid out as the Name cell, a `│` separator, a 9-cell Size value, one space, an 8-cell Date value (`MM-DD-YY`), one space, and a 5-cell Time value (`HH:MM`), so that every rendered Size, Date, and Time value appears untruncated and the row fills the panel interior exactly. At the 80×24 nominal size with the default 50/50 split, all four columns render. The active panel's path title SHALL render inverse (black on cyan) and the inactive panel's title SHALL render cyan on blue. + +#### Scenario: All four columns at the nominal size + +- **WHEN** a panel renders Full mode at terminal size 80×24 with the default 50/50 split +- **THEN** the header row reads `Name | Size | Date | Time` and all four columns render, with the Name column at least 12 display cells wide + +#### Scenario: Values render untruncated at the nominal size + +- **WHEN** a panel renders Full mode at 80×24 with the default split and an entry modified on 2026-06-27 at 23:30 +- **THEN** the row shows `06-27-26` and `23:30` complete, and a directory row shows `▶SUB-DIR◀` complete, with no blank cells between the Time value and the right border + +#### Scenario: Sizes are right-aligned + +- **WHEN** two file entries of 87 bytes and 35 KiB render in Full mode +- **THEN** `87` and `35K` are right-aligned within the Size column so their last digits share a cell column + +#### Scenario: Time drops first on a narrowing panel + +- **WHEN** a panel narrows to where rendering all four columns would leave the Name column under 12 display cells +- **THEN** the Time column and its header are dropped, and the remaining columns render with Name at 12 or more cells + +#### Scenario: Name-only at minimum panel width + +- **WHEN** a panel is at the 20-column minimum width +- **THEN** only the Name column renders, spanning the panel interior, and the header row shows only `Name` + +#### Scenario: Active panel title is inverse + +- **WHEN** a panel is the active panel +- **THEN** its centered path title in the top border SHALL be rendered with the `panel.title.active` role (black on cyan) + +#### Scenario: Inactive panel title is not inverse + +- **WHEN** a panel is not the active panel +- **THEN** its centered path title SHALL be rendered with the `panel.title.inactive` role (cyan on blue) + +#### Scenario: Sort column shows an indicator + +- **WHEN** Full mode renders the column header row and the panel is sorted by Name +- **THEN** the header SHALL show a `↓`/`↑` sort indicator next to the active sort column's label + +### Requirement: Entry row rendering + +The system SHALL render entry rows so directories appear in the bright-white directory style and files in the file style, with `▶UP--DIR◀` shown for the `..` entry and `▶SUB-DIR◀` shown complete in the Size column for directories; numeric sizes SHALL be right-aligned in that column. The entry under the cursor SHALL render as a full-width inverse bar using the `panel.cursor` role. Rendering SHALL use only ANSI-16 named color roles and CP437 box-drawing/geometric glyphs, with no emoji, Nerd Font, or file-type icons. + +#### Scenario: Directory entry styling + +- **WHEN** an entry is a directory +- **THEN** its name SHALL render in the `panel.directory` style and its Size column SHALL read `▶SUB-DIR◀` in full + +#### Scenario: Parent entry styling + +- **WHEN** the entry list contains the parent `..` entry +- **THEN** it SHALL render as `▶UP--DIR◀` in the `panel.directory` style + +#### Scenario: Cursor row is an inverse bar + +- **WHEN** an entry is under the panel cursor +- **THEN** that row SHALL render as a full-width inverse bar using the `panel.cursor` role diff --git a/openspec/changes/full-mode-column-widths/tasks.md b/openspec/changes/full-mode-column-widths/tasks.md new file mode 100644 index 0000000..501421c --- /dev/null +++ b/openspec/changes/full-mode-column-widths/tasks.md @@ -0,0 +1,23 @@ +# Tasks: full-mode-column-widths + +## 1. Renderer (`crates/filecommand-tui/src/views/panel.rs`) + +- [ ] 1.1 Add `SIZE_TEXT_W = 9`, `DATE_TEXT_W = 8`, `TIME_TEXT_W = 5` for the rendered text; leave `ColumnSet::reserved_w` returning today's 25 / 20 / 12 / 0 so `choose_columns` is untouched (design D1) +- [ ] 1.2 `format_full_row`: `│` directly followed by the right-aligned 9-cell size, then ` ` + 8-cell date, then ` ` + 5-cell time (panel-navigation "Full display mode layout": "Values render untruncated at the nominal size"; design D1, D2) +- [ ] 1.3 Add a right-pad helper (or reuse an existing one) for numeric sizes; keep `▶SUB-DIR◀`/`▶UP--DIR◀` left-filled (panel-navigation "Entry row rendering"; design D2) +- [ ] 1.4 Confirm the `choose_columns` unit tests (`choose_columns_drops_time_first` etc.) pass unchanged + +## 2. Tests + +- [ ] 2.1 `panel.rs` unit test: a 38-cell interior row with a directory entry renders `▶SUB-DIR◀`, `06-27-26`, `23:30` complete and fills exactly 38 cells (scenario "Values render untruncated at the nominal size") +- [ ] 2.2 `panel.rs` unit test: file sizes `87` and `35K` are right-aligned in the Size column (scenario "Sizes are right-aligned") +- [ ] 2.3 `cargo insta test --accept`, then review every changed `.snap`: only Size/Date/Time cells differ (design D3) + +## 3. Docs + +- [ ] 3.1 README "Panel display modes": note that sizes are right-aligned; optionally refresh `Screen/*.png` + +## 4. Verify + +- [ ] 4.1 `cargo test --workspace` green; `openspec validate full-mode-column-widths --strict` clean +- [ ] 4.2 Manual at 80×24 and at a wide terminal, with and without a git repository: all four columns, full dates and times, no trailing blank cells From 9cc4bafd7f60055cd08f35397fc549c47f1aac16 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:18 -0600 Subject: [PATCH 05/13] Add OpenSpec proposal line-editing-in-text-fields Introduce a shared LineEdit model (new text-field-editing capability) so every single-line field and the command line get a movable caret, Home/End, word keys, Delete, Ctrl+Backspace, and clipboard paste, with scrolling field rendering; panel key meanings are preserved. Co-Authored-By: Claude Fable 5.1 --- .../.openspec.yaml | 2 + .../line-editing-in-text-fields/design.md | 59 +++++++++++++++++++ .../line-editing-in-text-fields/proposal.md | 35 +++++++++++ .../specs/command-line/spec.md | 53 +++++++++++++++++ .../specs/file-action-menu/spec.md | 30 ++++++++++ .../specs/find-file/spec.md | 25 ++++++++ .../specs/fuzzy-jump/spec.md | 25 ++++++++ .../specs/operation-dialogs/spec.md | 31 ++++++++++ .../specs/text-field-editing/spec.md | 53 +++++++++++++++++ .../line-editing-in-text-fields/tasks.md | 36 +++++++++++ 10 files changed, 349 insertions(+) create mode 100644 openspec/changes/line-editing-in-text-fields/.openspec.yaml create mode 100644 openspec/changes/line-editing-in-text-fields/design.md create mode 100644 openspec/changes/line-editing-in-text-fields/proposal.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/tasks.md diff --git a/openspec/changes/line-editing-in-text-fields/.openspec.yaml b/openspec/changes/line-editing-in-text-fields/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/line-editing-in-text-fields/design.md b/openspec/changes/line-editing-in-text-fields/design.md new file mode 100644 index 0000000..983c557 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/design.md @@ -0,0 +1,59 @@ +# Design: line-editing-in-text-fields + +## Context + +Six fields, six copies of "push a char / pop a char": `FileOpInputChar/Backspace` (destination, rename, conflict rename — `update.rs:2948-3060`), `FindFileChar/Backspace`, `FuzzyJumpChar/Backspace`, `CommandLineChar/Backspace`. All state is a `String`. Renderers draw the string left-aligned in a fixed-width bracket field (`destination_input.rs` `FIELD_WIDTH = 40`) and the command line shows the text followed by `_` (responsive-layout already requires "Command line keeps the caret visible"). Over the panels Left/Right are unbound; Home/End/PgUp/PgDn move the panel cursor; Delete requests delete; Ctrl+Left/Right adjust the split; Up/Down recall history only while the line is non-empty. + +## Goals / Non-Goals + +**Goals:** + +- One model, one key map, one renderer helper. +- Classic NC input-line feel. +- No change to any existing panel key meaning. +- Paste from the system clipboard. + +**Non-Goals:** + +- Multi-line editing; selection within a field; mouse caret placement. +- The quick-filter/type-ahead patterns. +- The viewer/editor prompts (they share nothing with dialogs today and are short search patterns). +- Tab completion (separate proposal). + +## Decisions + +### D1: `LineEdit` in core, caret measured in chars + +`pub struct LineEdit { text: String, caret: usize /* char index */ }` with `insert(char)`, `insert_str(&str)`, `backspace()`, `delete()`, `left()`, `right()`, `home()`, `end()`, `word_left()`, `word_right()`, `delete_word_back()`, `delete_word_forward()`, `clear()`, `set_text_caret_end(String)`, `text()`, `caret()`, and `window(width) -> (start_char, caret_col)` for renderers. Word boundaries: runs of alphanumerics vs. anything else, so `C:\Users\kevin` word-jumps at `\`. Char-indexed to keep `unicode-width` out of core logic; renderers map char index → display column with `display_width`. + +Alternative rejected: byte-indexed caret — every operation would need UTF-8 boundary checks; char indexing is simpler and the fields are short. + +### D2: One `TextEditOp` command, routed by phase + +`Command::TextEdit(TextEditOp)` where `TextEditOp = Insert(char) | Backspace | Delete | Left | Right | Home | End | WordLeft | WordRight | DeleteWordBack | DeleteWordForward | Paste(String)`. `update` routes it to whichever field owns the keyboard (destination/rename/conflict-rename input, find-file, fuzzy-jump, else the command line). Existing `FileOpInputChar`/`FindFileChar`/`FuzzyJumpChar`/`CommandLineChar` and their Backspace commands become thin aliases of `TextEdit` (kept so current tests and the quick-search handoff compile unchanged) or are replaced in the same change — implementer's call, but no field may keep a private `String`. + +### D3: Command-line rules preserve every panel key + +Left/Right map to `TextEdit(Left/Right)` only when the line is non-empty (mirrors the Up/Down history rule); on an empty line they stay unbound. Home/End/PgUp/PgDn keep moving the panel cursor even mid-composition (unchanged, documented); Delete keeps opening the delete confirmation (unchanged, documented in the README today); Ctrl+Left/Right stay split keys; Ctrl+Backspace → `DeleteWordBack`. Printables insert at the caret. History recall (`recall_history`) and Ctrl+Enter/Ctrl+] (`paste_cursor_entry`) call `set_text_caret_end`. + +Alternative rejected: Home/End moving the caret while composing — it would silently take two of the most-used panel keys away whenever a single character is on the line. + +### D4: Paste is a TUI-side clipboard read turned into `Paste(String)` + +The key map emits `Command::RequestPaste`; the event loop reads clipboard text (`clipboard-win` `get_clipboard_string` on Windows, `arboard` elsewhere), normalizes CR/LF to a single space, and feeds `TextEdit(Paste(text))`. An unavailable/non-text clipboard is a no-op. Inside dialog fields Shift+Ins and Ctrl+V both map to it (today the unguarded `Char(c)` arm types a literal `v` on Ctrl+V there). Over the panels only Ctrl+V maps to it (unbound today), because the plain `KeyCode::Insert` arm has no modifier guard, so Shift+Ins is already the selection toggle and stays so; Ctrl+Shift+Ins remains clipboard-paths. + +Alternative rejected: Shift+Ins paste over the panels — it would silently take the selection toggle away from anyone who presses Insert with Shift held. + +### D5: Field rendering shows the caret and scrolls + +Each field renderer calls `window(field_w)` and draws `text[start..start+field_w]`, then positions the terminal cursor at the caret column (the frame's `set_cursor_position`; today's dialogs leave the cursor hidden — the plain command line already shows a `_` caret, which is replaced by the terminal cursor at the caret). Pre-filled fields open with the caret at the end so Enter-to-accept is unchanged. + +## Risks / Trade-offs + +- [Replacing `State.command_line: String` touches many tests] → A `text()` accessor and `set_text_caret_end` helper make the assertion changes mechanical. +- [Terminal cursor visibility inside dialogs is new chrome] → It only appears while a text field is focused and is hidden again on close. +- [Ctrl+Backspace on some terminals arrives as `Ctrl+H` or `0x7f`] → Both are mapped to `DeleteWordBack` when the Ctrl modifier is reported, otherwise plain Backspace; best-effort, documented. + +## Open Questions + +- None. diff --git a/openspec/changes/line-editing-in-text-fields/proposal.md b/openspec/changes/line-editing-in-text-fields/proposal.md new file mode 100644 index 0000000..cc3a91f --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/proposal.md @@ -0,0 +1,35 @@ +# Change: line-editing-in-text-fields + +## Why + +Every single-line text field in FileCommand is append-only: the F5/F6/F7 destination and name field, the file-action Rename field, the overwrite-conflict Rename field, the Alt+F7 find pattern, the Ctrl+J fuzzy pattern, and the command line accept only printable characters and Backspace (`input/mod.rs` `map_file_op_setup_key`, `map_file_op_running_key`, and the find/fuzzy/command-line arms). Changing one character in the middle of `report_final_v2.txt` means backspacing through the suffix and retyping it; a long destination path cannot be edited except from its end; nothing can be pasted. Norton Commander's input lines had a movable cursor, Home/End, and insert-at-cursor. This is the single largest everyday friction in the dialogs. + +## What Changes + +- A shared **line-edit model** in `filecommand-core` (`LineEdit { text, caret }`) with insert-at-caret, Backspace, Delete, Left/Right, Home/End, word left/right, delete word back/forward, clear, and paste. Every single-line field above is backed by it. +- **Dialog fields** (destination/mkdir, Rename, conflict Rename, find-file pattern, fuzzy-jump pattern) honor Left/Right, Home/End, Delete, Ctrl+Left/Ctrl+Right, Ctrl+Backspace/Ctrl+Delete, and paste via Shift+Ins or Ctrl+V. A pre-filled field opens with the caret at its end. +- **Command line**: printable keys insert at the caret; Left/Right move the caret while the line is non-empty (on an empty line they have no command-line meaning, exactly like Up/Down today); Ctrl+Backspace deletes the word before the caret; Ctrl+V pastes. Home/End, PgUp/PgDn, Delete, Shift+Ins, and Ctrl+Left/Right keep their panel meanings (cursor jump, delete-file confirmation, selection toggle, split adjust). History recall and Ctrl+Enter/Ctrl+] paste leave the caret at the end. +- **Rendering**: each field shows the terminal cursor at the caret and scrolls its visible window horizontally so the caret is always visible; the bracket-and-dots field style is unchanged. +- Out of scope: the quick-filter and type-ahead patterns (search-as-you-type, not editable lines), the viewer F7 and editor F4/F7 prompts, mouse caret placement, Tab completion. + +## Capabilities + +### New Capabilities + +- `text-field-editing`: the caret model, the key set, paste, and the scrolling field rendering shared by every single-line text field. + +### Modified Capabilities + +- `command-line`: "Command-line prompt and printable-key routing" gains caret insertion and the non-empty-line Left/Right rule; "Paste filename and path to command line" states the caret lands at the end. +- `operation-dialogs`: "Destination input dialog" references the shared editing keys. +- `file-action-menu`: "In-place Rename" opens with the caret at the end of the pre-filled name and honors the shared keys. +- `find-file`: "Find-file invocation" — the pattern field is a shared text field. +- `fuzzy-jump`: "Fuzzy jump dialog invocation" — the pattern field is a shared text field. + +## Impact + +- `crates/filecommand-core/src/line_edit.rs` (new) — model + unit tests; `fs_ops/dialog.rs` (`DestinationInput.input`, `RenameInput.input`, conflict `rename_input`), `find_file.rs` (`pattern`), `quicksearch.rs` (`FuzzyJumpState.pattern`), `update.rs` (`State.command_line` becomes a `LineEdit`; new `Command::TextEdit(TextEditOp)` routed by phase; `Command::RequestPaste` and `Effect::ReadClipboardText`). +- `crates/filecommand-tui/src/input/mod.rs` — one `map_text_edit_key` helper used by every field; command-line arms for Left/Right/Ctrl+Backspace/paste. `app.rs` — clipboard **read** (text) via the existing `clipboard-win`/`arboard` dependencies; terminal cursor placement while a field is focused. +- `crates/filecommand-tui/src/views/{destination_input,find_file,fuzzy_jump,command_line}.rs` — caret placement and horizontal window. +- Tests: `update/tests.rs`, `input/tests.rs`, snapshot additions for a scrolled field. +- Non-breaking for existing key meanings over the panels (Left/Right were unbound there). diff --git a/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md b/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md new file mode 100644 index 0000000..77d8909 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md @@ -0,0 +1,53 @@ +## MODIFIED Requirements + +### Requirement: Command-line prompt and printable-key routing + +The system SHALL render a command line below the panels showing a shell prompt with the active panel's current path (e.g. `C:\NORTON>`), and while a panel is focused and no quick-search or dialog is active, printable keys SHALL be inserted into the command-line buffer at its caret rather than sent to the panel, matching classic NC behavior. The buffer SHALL be backed by the shared line-edit model (`text-field-editing`). While the buffer is non-empty, Left and Right SHALL move the caret and Ctrl+Backspace SHALL delete the word before it; while the buffer is empty, Left and Right SHALL have no command-line meaning. Home, End, PgUp, PgDn, Delete, and Ctrl+Left/Ctrl+Right SHALL keep their panel meanings regardless of buffer content. + +The prompt SHALL follow the active panel: switching panels or navigating the active panel to a new directory SHALL update the displayed prompt path to that panel's current path. + +Because printable keys route to the command line while a panel is focused, the command line is a distinct typing sink from the §4.7 type-ahead quick-search: when quick-search mode is active it consumes plain printable keys, and only one sink SHALL consume any given key. + +#### Scenario: Prompt shows active panel path +- **WHEN** the left panel is active and its current directory is `C:\NORTON` +- **THEN** the command line renders the prompt `C:\NORTON>` followed by the (empty) command buffer + +#### Scenario: Prompt updates on panel switch +- **WHEN** the right panel's directory is `D:\WORK` and the user presses Tab to make the right panel active +- **THEN** the command-line prompt updates to `D:\WORK>` + +#### Scenario: Printable key routes to command line +- **WHEN** a panel is focused, no quick-search or dialog is active, and the user types `d`, `i`, `r` +- **THEN** the command-line buffer becomes `dir` and the panel cursor does not move + +#### Scenario: Caret editing mid-line +- **WHEN** the buffer holds `cd srv` and the user presses Left, Left, then types `c` +- **THEN** the buffer reads `cd scrv` with the caret after the `c` and the panel cursor does not move + +#### Scenario: Left on an empty line does nothing to the panel +- **WHEN** the buffer is empty and the user presses Left +- **THEN** neither the buffer nor the panel cursor changes + +#### Scenario: Home still moves the panel cursor while composing +- **WHEN** the buffer holds `cd docs` and the user presses Home +- **THEN** the panel cursor jumps to the first entry and the buffer and its caret are unchanged + +#### Scenario: Quick-search mode captures printables instead +- **WHEN** type-ahead quick-search mode is active and the user types a printable key +- **THEN** the key extends the quick-search pattern and the command-line buffer is left unchanged + +### Requirement: Paste filename and path to command line + +The system SHALL paste the cursor entry's filename onto the command line when Ctrl+Enter is pressed, and the cursor entry's full path when Ctrl+] is pressed, appending to the end of the command-line buffer (after a separating space when the buffer is non-empty and does not already end in one) and leaving the caret at the end. Ctrl+] (ASCII 0x1D) SHALL be available on all platforms; Ctrl+Enter SHALL be available on Windows and best-effort elsewhere (available only where the kitty keyboard protocol delivers it). Both bindings SHALL be overridable in `config.toml`. + +#### Scenario: Ctrl+Enter pastes filename +- **WHEN** the cursor is on entry `README.md` in `C:\NORTON` and the user presses Ctrl+Enter +- **THEN** `README.md` is inserted at the end of the command-line buffer and the caret is at the end + +#### Scenario: Ctrl+] pastes full path +- **WHEN** the cursor is on entry `README.md` in `C:\NORTON` and the user presses Ctrl+] +- **THEN** `C:\NORTON\README.md` is inserted at the end of the command-line buffer and the caret is at the end + +#### Scenario: Paste after caret editing lands at the end +- **WHEN** the buffer holds `del ` with the caret after `d`, and the user presses Ctrl+Enter on `old.log` +- **THEN** the buffer reads `del old.log` and the caret is at the end diff --git a/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md b/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md new file mode 100644 index 0000000..8844811 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: In-place Rename + +The Rename menu entry SHALL open an input dialog pre-filled with the target entry's current name, with the caret at the end of the field; the field SHALL be a shared text field per `text-field-editing`. Accepting the dialog SHALL rename the entry within its current directory using the existing same-volume rename machinery, including identity-aware case-only rename. Cancelling with Esc SHALL rename nothing. A rename that collides with an existing name or fails SHALL surface the existing overwrite-conflict or error-recovery dialogs from `operation-dialogs`. On successful rename the affected panel SHALL be re-read automatically. + +#### Scenario: Rename pre-fills the current name + +- **WHEN** the user activates Rename for `draft.txt` +- **THEN** an input dialog opens pre-filled with `draft.txt` and the caret at the end of the field + +#### Scenario: Editing the stem without retyping the extension + +- **WHEN** the rename dialog for `draft.txt` is open and the user presses Home, then Ctrl+Right, then types `-v2` +- **THEN** the field reads `draft-v2.txt` + +#### Scenario: Accepting renames in place + +- **WHEN** the rename dialog for `draft.txt` is accepted with the value `final.txt` +- **THEN** `draft.txt` is renamed to `final.txt` in the same directory and the panel is re-read + +#### Scenario: Case-only rename works + +- **WHEN** the rename dialog for `readme.md` is accepted with the value `README.md` +- **THEN** the entry's name case changes to `README.md` using the identity-aware case-only rename path + +#### Scenario: Esc renames nothing + +- **WHEN** the rename dialog is open and the user presses Esc +- **THEN** the dialog closes and the entry keeps its original name diff --git a/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md b/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md new file mode 100644 index 0000000..2580dac --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Find-file invocation + +The system SHALL open a find-file dialog when the user presses Alt+F7 or selects Commands → Find file, presenting an input field for the name pattern to search for. The field SHALL be a shared text field per `text-field-editing`. The dialog SHALL render in the primary dialog style (black text on cyan, black double-line frame) with a bracket-and-dots input field per §4.4, using only §4.11 ANSI-16 roles and CP437-heritage single-cell glyphs. + +#### Scenario: Alt+F7 opens the dialog + +- **WHEN** a panel is focused and the user presses Alt+F7 +- **THEN** the find-file dialog opens centered over the panels with an empty, focused name-pattern input field + +#### Scenario: Menu entry opens the dialog + +- **WHEN** the user selects Commands → Find file from the F9 pull-down menu +- **THEN** the same find-file dialog opens, identically to the Alt+F7 path + +#### Scenario: Pattern is editable mid-string + +- **WHEN** the pattern field reads `reprt` and the user presses Left, Left, then types `o` +- **THEN** the field reads `report` + +#### Scenario: Dialog uses the primary style and permitted glyphs + +- **WHEN** the find-file dialog is rendered +- **THEN** it uses the `dialog.primary` and `dialog.input` roles with no icons, emoji, or non-CP437 glyphs diff --git a/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md b/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md new file mode 100644 index 0000000..4996559 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Fuzzy jump dialog invocation + +The system SHALL open the fuzzy jump dialog when the user presses Ctrl+J (the default binding, overridable via `config.toml`), presenting a modal list of previously visited directories drawn in the standard NC dialog style over the panels. The dialog SHALL provide a single-line input field — a shared text field per `text-field-editing` — into which the user types a fuzzy match pattern, and Esc SHALL close the dialog without changing the active panel's directory. + +#### Scenario: Ctrl+J opens the dialog + +- **WHEN** the user presses Ctrl+J while a panel is focused +- **THEN** the fuzzy jump dialog opens as a modal box over the panels with an empty input field and the visited-directory list shown beneath it + +#### Scenario: Esc closes without navigating + +- **WHEN** the fuzzy jump dialog is open and the user presses Esc +- **THEN** the dialog closes and the active panel remains at its current directory unchanged + +#### Scenario: Pattern edited mid-string re-narrows the list + +- **WHEN** the pattern reads `prj` and the user presses Left, then types `o` +- **THEN** the pattern reads `proj` and the list is re-narrowed against `proj` + +#### Scenario: Overridden binding still opens the dialog + +- **WHEN** `config.toml` rebinds fuzzy jump to a different key and the user presses that key +- **THEN** the fuzzy jump dialog opens exactly as it would for the default Ctrl+J binding diff --git a/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md b/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md new file mode 100644 index 0000000..dadf9be --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Destination input dialog + +The system SHALL present, when the user starts a Copy (F5) or Rename/Move (F6) operation, a primary-style input dialog whose editable destination field is pre-filled with the opposite panel's current path, so the common "copy to the other panel" case requires no typing. The field SHALL be a shared text field per `text-field-editing` (caret keys, word keys, paste, scrolling window), opening with the caret at the end of the pre-filled path. + +#### Scenario: Destination pre-filled from the opposite panel + +- **WHEN** the user presses F5 with the left panel active and the right panel showing `D:\backup` +- **THEN** the destination input dialog opens with its field pre-filled with `D:\backup` +- **AND** the caret is at the end of the field so the user can edit or accept it + +#### Scenario: Editing the pre-filled destination in place + +- **WHEN** the field reads `D:\backup` and the user presses Home, Delete, then types `E` +- **THEN** the field reads `E:\backup` + +#### Scenario: Accepting the pre-filled destination starts the job + +- **WHEN** the destination input dialog is open with a valid destination and the user activates the default button with Enter +- **THEN** the dialog closes and the operation begins against the entered destination + +#### Scenario: Cancelling the destination dialog aborts the operation + +- **WHEN** the destination input dialog is open and the user presses Esc +- **THEN** the dialog closes and no file operation is started + +#### Scenario: Manual UNC destination is accepted + +- **WHEN** the user clears the field and types a UNC destination such as `\\server\share\dest` +- **THEN** the entered UNC path is used as the operation destination diff --git a/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md b/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md new file mode 100644 index 0000000..d2f6cec --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Caret model and editing keys + +Every single-line text field — the Copy/Move destination and Make-directory name field, the file-action Rename field, the overwrite-conflict Rename field, the find-file pattern, the fuzzy-jump pattern, and the command line — SHALL be backed by one shared line-edit model holding the text and a caret position. Printable characters SHALL be inserted at the caret; Backspace SHALL delete the character before the caret and Delete the character after it; Left/Right SHALL move the caret by one character; Home/End SHALL move it to the start/end; Ctrl+Left/Ctrl+Right SHALL move it by one word; Ctrl+Backspace/Ctrl+Delete SHALL delete the word before/after the caret. A field that opens pre-filled SHALL open with the caret at the end of the text. The command line SHALL apply these keys subject to the exceptions in `command-line` "Command-line prompt and printable-key routing" (keys that keep their panel meaning). The model SHALL live in `filecommand-core` with all mutations flowing through `core::update`. + +#### Scenario: Insert in the middle of a pre-filled name +- **WHEN** the Rename field opens pre-filled with `draft.txt`, and the user presses Left five times, Backspace, then types `i` +- **THEN** the field reads `drift.txt` with the caret after the `i` + +#### Scenario: Home then edit a destination prefix +- **WHEN** the destination field holds `D:\backup\2026`, and the user presses Home, Delete twice, then types `\\server\share` +- **THEN** the field reads `\\server\share\backup\2026` + +#### Scenario: Word delete +- **WHEN** the field holds `C:\Users\kevin\docs` with the caret at the end and the user presses Ctrl+Backspace +- **THEN** the field reads `C:\Users\kevin\` + +#### Scenario: Pre-filled field accepts unchanged with Enter +- **WHEN** the destination field opens pre-filled with the opposite panel's path and the user presses Enter without any editing key +- **THEN** the operation starts against that path exactly as before this capability existed + +### Requirement: Paste into a text field + +The system SHALL paste the system clipboard's text at the caret of the focused dialog text field on Shift+Ins or Ctrl+V, with any line breaks replaced by a single space. When the clipboard holds no text, the key SHALL have no effect. Over the panels, Ctrl+V SHALL paste into the command line, while Shift+Ins SHALL keep its selection-toggle meaning and Ctrl+Shift+Ins SHALL remain the clipboard-paths action. + +#### Scenario: Paste a path into the destination field +- **WHEN** the clipboard holds `E:\archive` and the destination field is focused with the caret at the end of `D:\` +- **THEN** after Ctrl+V the field reads `D:\E:\archive` with the caret at the end + +#### Scenario: Multi-line clipboard text is flattened +- **WHEN** the clipboard holds two lines and the user presses Ctrl+V over the panels +- **THEN** the two lines are inserted into the command line joined by one space, and the panel cursor does not move + +#### Scenario: Shift+Ins over the panels still toggles selection +- **WHEN** the clipboard holds text and the user presses Shift+Ins with the panels focused +- **THEN** the cursor entry's selection is toggled and nothing is pasted + +#### Scenario: Empty clipboard is a no-op +- **WHEN** the clipboard holds no text and the user presses Ctrl+V in a focused field +- **THEN** the field and caret are unchanged + +### Requirement: Field rendering keeps the caret visible + +Each text field SHALL render a window of its text sized to the field width, scrolled so the caret is always within the window, and SHALL show the terminal cursor at the caret's screen position while the field is focused. The bracket-and-dots field style, dialog geometry, and roles SHALL be unchanged. + +#### Scenario: Long destination scrolls to the caret +- **WHEN** the destination field (40 cells) holds a 60-character path and the caret is at the end +- **THEN** the field shows the last 40 characters and the terminal cursor sits at the field's last cell + +#### Scenario: Caret moved left scrolls the window back +- **WHEN** the field above is focused and the user presses Home +- **THEN** the field shows the first 40 characters and the terminal cursor sits at the field's first cell diff --git a/openspec/changes/line-editing-in-text-fields/tasks.md b/openspec/changes/line-editing-in-text-fields/tasks.md new file mode 100644 index 0000000..8a8340d --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/tasks.md @@ -0,0 +1,36 @@ +# Tasks: line-editing-in-text-fields + +## 1. Core model (`crates/filecommand-core/src/line_edit.rs`, new) + +- [ ] 1.1 `LineEdit` per design D1 with all operations and `window(width)` +- [ ] 1.2 Unit tests: insert mid-line, backspace/delete at ends, word jumps across `\` and spaces, delete-word, paste with CR/LF normalized, `window` keeps the caret visible for long text + +## 2. State and reducer (`crates/filecommand-core`) + +- [ ] 2.1 `fs_ops/dialog.rs`: `DestinationInput.input`, `RenameInput.input`, conflict `rename_input` become `LineEdit` (pre-filled → caret at end) (text-field-editing "Caret model and editing keys"; file-action-menu "In-place Rename") +- [ ] 2.2 `find_file.rs` `pattern`, `quicksearch.rs` `FuzzyJumpState.pattern` become `LineEdit`; search-narrowing reads `text()` +- [ ] 2.3 `update.rs`: `State.command_line: LineEdit`; `Command::TextEdit(TextEditOp)` routed by phase (design D2); `recall_history`, `paste_cursor_entry`, `CommandLineClear`, `run_command_line` use `text()`/`set_text_caret_end`/`clear` (command-line "Paste filename and path to command line": caret at end) +- [ ] 2.4 `Command::RequestPaste` → `Effect::ReadClipboardText`; `TextEdit(Paste)` inserts at the caret (text-field-editing "Paste into a text field"; design D4) +- [ ] 2.5 Tests in `update/tests.rs`: destination field edit in the middle; rename `draft.txt` → `drift.txt` via Left×5, Backspace, `i`; command line Left/Right only when non-empty; Home still moves the panel cursor mid-composition; history recall puts the caret at the end; paste normalizes newlines + +## 3. Input mapping (`crates/filecommand-tui/src/input/mod.rs`) + +- [ ] 3.1 `map_text_edit_key(key) -> Option` covering Left/Right/Home/End/Delete/Backspace/Ctrl+Left/Ctrl+Right/Ctrl+Backspace/Ctrl+Delete/Shift+Ins/Ctrl+V/printables; used by the destination, rename, conflict-rename, find-file, and fuzzy-jump arms +- [ ] 3.2 Panel arm: Left/Right → caret moves when `typing`; Ctrl+Backspace → `DeleteWordBack`; Ctrl+V → `RequestPaste`; Home/End/Delete/Shift+Ins/Ctrl+Left/Right unchanged (command-line "Command-line prompt and printable-key routing"; design D3, D4) +- [ ] 3.3 `input/tests.rs`: each mapping above, plus "Left on an empty line maps to nothing", "Shift+Ins over the panels still toggles selection", and "Ctrl+Shift+Ins is still clipboard paths" + +## 4. Rendering (`crates/filecommand-tui/src/views/*`) + +- [ ] 4.1 Shared `draw_text_field(buf, x, y, width, &LineEdit, style) -> caret_x` using `window()`; used by `destination_input.rs`, `find_file.rs`, `fuzzy_jump.rs` (text-field-editing "Field rendering keeps the caret visible"; design D5) +- [ ] 4.2 `views/command_line.rs`: window around the caret, terminal cursor at the caret replacing the drawn `_` (responsive-layout "Command line keeps the caret visible" still holds) +- [ ] 4.3 `app.rs`: set the terminal cursor position after drawing when a text field is focused; hide it otherwise; implement `Effect::ReadClipboardText` with `clipboard-win`/`arboard` +- [ ] 4.4 Snapshot tests: a destination field longer than 40 cells scrolled so the caret is visible; command line with the caret mid-text + +## 5. Docs + +- [ ] 5.1 README "Command line → Typing" table and "Keyboard reference": caret keys, Ctrl+Backspace, paste; F1 Help "Command line" and "File operations" pages + +## 6. Verify + +- [ ] 6.1 `cargo test --workspace` green; `openspec validate line-editing-in-text-fields --strict` clean +- [ ] 6.2 Manual: F6 on a file, Home, type a prefix, Enter; F5, End, Backspace×3, type, Enter; command line `cd ` + Shift+Ins of a copied path; Ctrl+J pattern edited mid-string From fcd096bb83a7316994f96bc5d7f74eb348d24beb Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:18 -0600 Subject: [PATCH 06/13] Add OpenSpec proposal command-line-clear-key Bind Ctrl+Y (rebindable via key.clear_line) to the existing CommandLineClear command over the panels, ending any history recall; Esc keeps requesting quit. Co-Authored-By: Claude Fable 5.1 --- .../command-line-clear-key/.openspec.yaml | 2 + .../changes/command-line-clear-key/design.md | 43 +++++++++++++++++ .../command-line-clear-key/proposal.md | 29 ++++++++++++ .../specs/command-line/spec.md | 47 +++++++++++++++++++ .../changes/command-line-clear-key/tasks.md | 25 ++++++++++ 5 files changed, 146 insertions(+) create mode 100644 openspec/changes/command-line-clear-key/.openspec.yaml create mode 100644 openspec/changes/command-line-clear-key/design.md create mode 100644 openspec/changes/command-line-clear-key/proposal.md create mode 100644 openspec/changes/command-line-clear-key/specs/command-line/spec.md create mode 100644 openspec/changes/command-line-clear-key/tasks.md diff --git a/openspec/changes/command-line-clear-key/.openspec.yaml b/openspec/changes/command-line-clear-key/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/command-line-clear-key/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/command-line-clear-key/design.md b/openspec/changes/command-line-clear-key/design.md new file mode 100644 index 0000000..4773a7a --- /dev/null +++ b/openspec/changes/command-line-clear-key/design.md @@ -0,0 +1,43 @@ +# Design: command-line-clear-key + +## Context + +`update.rs:1349` — `Command::CommandLineClear` clears `command_line` and `history_cursor`; nothing outside tests emits it today (`run_command_line` clears the buffer directly after Enter). `Keys` (`config.rs:67-118`) already models overridable chords with `KeyBinding::new(ctrl, alt, shift, key)` and `matches_binding` in `input/mod.rs`. Ctrl+Y is unbound over the panels (the `Char(c)` arm requires `is_plain`). The quit-confirm, file-op DeleteConfirm, and editor quit-confirm arms match `Char('y')` without a modifier guard, so Ctrl+Y currently acts as a plain `y` there, and the destination/rename fields type a literal `y`; those keymaps are not touched by this change. + +## Goals / Non-Goals + +**Goals:** + +- One obvious key to abandon a typed line. +- Keep the user's Esc-quits decision. +- Follow the `key.*` convention. + +**Non-Goals:** + +- Changing Esc. +- Clearing the quick filter or type-ahead pattern (they have their own exits). +- A clear key inside dialog fields (Home + Ctrl+Delete covers it once `line-editing-in-text-fields` lands). + +## Decisions + +### D1: Ctrl+Y, fixed meaning, rebindable chord + +Ctrl+Y is free on every host terminal crossterm supports and does not collide with Ctrl+U, which `pulldown-menu-completion` assigns to Swap panels (NC's own Ctrl+U). `key.clear_line = "ctrl+y"` is the default. + +Alternative rejected: Ctrl+U (Unix line-kill) — it is NC's Swap panels chord and would collide with that proposal. + +### D2: No-op on an empty line + +The chord only emits `CommandLineClear` when `typing` is true; on an empty line it falls through to nothing, so a mistaken press never does anything surprising. + +### D3: Reuse `CommandLineClear` verbatim + +No new command. Its existing behavior (clear text + reset `history_cursor`) is exactly "abandon the line, including a half-recalled history entry". With `line-editing-in-text-fields` applied, the same command clears the `LineEdit` and resets the caret. + +## Risks / Trade-offs + +- [Users of Unix-style Ctrl+U may expect it to clear] → The README notes the rebinding; `key.clear_line = "ctrl+u"` would then shadow Swap panels, documented as the user's choice. + +## Open Questions + +- None. diff --git a/openspec/changes/command-line-clear-key/proposal.md b/openspec/changes/command-line-clear-key/proposal.md new file mode 100644 index 0000000..859f871 --- /dev/null +++ b/openspec/changes/command-line-clear-key/proposal.md @@ -0,0 +1,29 @@ +# Change: command-line-clear-key + +## Why + +Since `quit-keys`, Esc over the panels requests quit instead of clearing the command line, and the spec deliberately says so ("Esc SHALL NOT clear the buffer"). The only way to discard a typed line is to Backspace through it. `Command::CommandLineClear` already exists in `core::update` (it clears the text and resets the history cursor) but no key emits it. A single chord fixes this without reopening the Esc decision. + +## What Changes + +- **Ctrl+Y** clears the command line while the panels own input; it also ends any history recall in progress. On an empty line it does nothing. Rebindable via `key.clear_line` in `config.toml`, following the existing `key.*` override mechanism. +- Esc behavior is unchanged (still requests quit). The chord is bound only over the panels; the keymaps of the built-in editor, viewer, menus, and dialogs are not touched (today their unguarded `y` arms treat Ctrl+Y as a plain `y`, which stays as is). +- README and F1 Help list the key. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `command-line`: "Command history navigation" names the clear key as the second way (besides Backspace) to hand Up/Down back to the panel; a new requirement "Clear the command line with a dedicated key" defines the binding, its no-op on an empty line, and its rebinding. + +## Impact + +- `crates/filecommand-core/src/config.rs` — `Keys.clear_line` (default Ctrl+Y), parsed from `key.clear_line`. +- `crates/filecommand-tui/src/input/mod.rs` — panel arm emitting `Command::CommandLineClear` when the chord matches and the buffer is non-empty. +- `crates/filecommand-tui/src/app.rs` `apply_config` wiring; README key tables; F1 Help "Command line" page. +- Tests: `input/tests.rs`, `config.rs` parsing, one `update/tests.rs` assertion that `CommandLineClear` also drops `history_cursor` (already true). +- Non-breaking; additive. diff --git a/openspec/changes/command-line-clear-key/specs/command-line/spec.md b/openspec/changes/command-line-clear-key/specs/command-line/spec.md new file mode 100644 index 0000000..3eff090 --- /dev/null +++ b/openspec/changes/command-line-clear-key/specs/command-line/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Clear the command line with a dedicated key + +The system SHALL clear the entire command-line buffer when the user presses the clear-line chord (default Ctrl+Y) while the panels own input and the buffer is non-empty; clearing SHALL also end any history recall in progress so that Up/Down return to moving the panel cursor. On an empty buffer the chord SHALL have no effect. The chord SHALL be overridable via `key.clear_line` in `config.toml` using the existing keybinding-override mechanism. Esc SHALL continue to request quit (application-shell "Quit request keys and confirmation"); the chord SHALL be bound only while the panels own input, and the existing key handling of menus, dialogs, the viewer, and the editor SHALL be unchanged. + +#### Scenario: Ctrl+Y clears a composed line +- **WHEN** the buffer holds `cd Program Files` and the user presses Ctrl+Y +- **THEN** the buffer is empty, the panel cursor has not moved, and no dialog opens + +#### Scenario: Ctrl+Y on an empty line does nothing +- **WHEN** the buffer is empty and the user presses Ctrl+Y +- **THEN** nothing changes: no quit dialog, no cursor movement + +#### Scenario: Clearing ends history recall +- **WHEN** the user has recalled a history entry with Up and then presses Ctrl+Y, then Up again +- **THEN** the buffer is empty after Ctrl+Y and the second Up moves the panel cursor instead of recalling history + +#### Scenario: Rebinding the clear key +- **WHEN** `config.toml` sets `key.clear_line = "ctrl+k"` and the buffer holds text +- **THEN** Ctrl+K clears the buffer and Ctrl+Y no longer does + +## MODIFIED Requirements + +### Requirement: Command history navigation + +The system SHALL maintain a command history and, while the command-line buffer is non-empty, Up and Down SHALL navigate previous and next history entries into the buffer. While the buffer is empty, Up and Down SHALL instead move the panel cursor. Backspacing the buffer to empty, or pressing the clear-line chord (default Ctrl+Y — see "Clear the command line with a dedicated key"), SHALL be the mechanisms that hand Up/Down back to the panel; Esc SHALL NOT clear the buffer — over the panels it requests application quit (application-shell "Quit request keys and confirmation"). Command history SHALL persist to `history.json`, written atomically. + +#### Scenario: Up recalls previous command while composing + +- **WHEN** the command buffer contains text and the user presses Up +- **THEN** the buffer is replaced with the previous history entry and the panel cursor does not move + +#### Scenario: Backspacing to empty releases Up/Down to panel + +- **WHEN** the command buffer is non-empty and the user backspaces until it is empty, then presses Up +- **THEN** the subsequent Up moves the panel cursor and recalls no history entry + +#### Scenario: Esc does not clear the buffer + +- **WHEN** the command buffer is non-empty and the user presses Esc +- **THEN** the buffer is not cleared and the quit-confirmation dialog opens instead + +#### Scenario: Executed command persisted to history + +- **WHEN** the user runs a command with Enter +- **THEN** the command is appended to the history and `history.json` is written atomically diff --git a/openspec/changes/command-line-clear-key/tasks.md b/openspec/changes/command-line-clear-key/tasks.md new file mode 100644 index 0000000..abcc6f3 --- /dev/null +++ b/openspec/changes/command-line-clear-key/tasks.md @@ -0,0 +1,25 @@ +# Tasks: command-line-clear-key + +## 1. Config (`crates/filecommand-core/src/config.rs`) + +- [ ] 1.1 `Keys.clear_line: KeyBinding`, default `ctrl+y`; parse `key.clear_line` (command-line "Clear the command line with a dedicated key": "Rebinding the clear key"; design D1) +- [ ] 1.2 Parser tests for the override and for the default + +## 2. Input (`crates/filecommand-tui/src/input/mod.rs`) + +- [ ] 2.1 Panel arm: when `typing` and the key matches `keys.clear_line`, emit `Command::CommandLineClear`; empty line → no command (scenarios "Ctrl+Y clears a composed line", "Ctrl+Y on an empty line does nothing"; design D2, D3) +- [ ] 2.2 `app.rs` `apply_config`: wire `keys.clear_line` like the other overridable chords +- [ ] 2.3 `input/tests.rs`: both scenarios; `CommandLineClear` is never emitted while a dialog, menu, viewer, or editor owns input (their existing handling is unchanged) + +## 3. Reducer test (`crates/filecommand-core/src/update/tests.rs`) + +- [ ] 3.1 Recall a history entry with Up, send `CommandLineClear` → buffer empty, `history_cursor` is `None`, next Up moves the panel cursor (scenario "Clearing ends history recall") + +## 4. Docs + +- [ ] 4.1 README: "Typing" table row for Ctrl+Y, `key.clear_line` in the config key list and the overridable-bindings sentence; F1 Help "Command line" page + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `openspec validate command-line-clear-key --strict` clean +- [ ] 5.2 Manual: type text, Ctrl+Y → empty; Up → panel cursor moves; rebind to `ctrl+k` in `config.toml` → Ctrl+K clears From 1f4b6bdd8a96799260ffc0a22b1c4c1ef0e536c6 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:18 -0600 Subject: [PATCH 07/13] Add OpenSpec proposal hidden-entry-styling Carry the hidden/system attribute on listing entries and render them with a new panel.hidden theme role in Full and Brief modes, with drag > cursor > selected > hidden > file/directory precedence; no show/hide toggle. Co-Authored-By: Claude Fable 5.1 --- .../hidden-entry-styling/.openspec.yaml | 2 + .../changes/hidden-entry-styling/design.md | 54 +++++++++++++++++++ .../changes/hidden-entry-styling/proposal.md | 32 +++++++++++ .../specs/additional-panel-modes/spec.md | 35 ++++++++++++ .../specs/panel-navigation/spec.md | 35 ++++++++++++ .../specs/theme-system/spec.md | 17 ++++++ .../changes/hidden-entry-styling/tasks.md | 26 +++++++++ 7 files changed, 201 insertions(+) create mode 100644 openspec/changes/hidden-entry-styling/.openspec.yaml create mode 100644 openspec/changes/hidden-entry-styling/design.md create mode 100644 openspec/changes/hidden-entry-styling/proposal.md create mode 100644 openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md create mode 100644 openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md create mode 100644 openspec/changes/hidden-entry-styling/specs/theme-system/spec.md create mode 100644 openspec/changes/hidden-entry-styling/tasks.md diff --git a/openspec/changes/hidden-entry-styling/.openspec.yaml b/openspec/changes/hidden-entry-styling/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/hidden-entry-styling/design.md b/openspec/changes/hidden-entry-styling/design.md new file mode 100644 index 0000000..3a57136 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/design.md @@ -0,0 +1,54 @@ +# Design: hidden-entry-styling + +## Context + +`listing.rs:438-451` builds `RawDirEntry{name,is_dir,size,modified}` from `entry.metadata()`; converting to `Entry` at `:455`. `theme.rs` roles are an enum + `ALL_ROLES`; `Theme::build` rejects a missing role. `panel.rs` chooses a row style in Full mode as drag target → cursor → directory/file (`:596-604`), with the selected style applied by the name-cell styling; the Brief renderer (`:709-719`) consults the selection inline in the same drag → cursor → selected → directory/file order. + +## Goals / Non-Goals + +**Goals:** + +- Hidden/system entries recognizable at a glance in every theme. +- Zero behavioral change beyond color. +- Attribute read from the enumeration (no per-file stat). + +**Non-Goals:** + +- A show/hide toggle. +- Attribute letters in the mini-status. +- Tree mode and Quick view styling. +- An Attributes dialog. + +## Decisions + +### D1: `hidden` is computed in the reader, once + +Windows: `metadata.file_attributes() & (0x2 | 0x4) != 0`. Non-Windows: `name` begins with `.`. Stored on `RawDirEntry` and copied to `Entry`, so sorting/filtering code never recomputes it. The fake readers used by tests gain the field with a default of `false`. + +Alternative rejected: computing it lazily in the renderer from the name — the renderer has no attribute access, and the dot-prefix rule is wrong on Windows. + +### D2: One new role, dimmer than `panel.file` on the same background + +| Theme | `panel.hidden` ANSI fg / bg | Truecolor fg (bg as the theme's file bg) | +|---|---|---| +| nc-classic | bright-black / blue | none | +| nc-mono | bright-black / black | none | +| terminal-green | bright-black / black | `#1E7A1E` | +| purple-lights | blue / magenta | `#6E5A80` on `#300040` | +| yellow-storm | bright-black / black | `#806000` | +| inverted | bright-black / bright-white | none | + +Each pair must remain legible but visibly subordinate to `panel.file`; the existing theme contrast tests are extended to assert `panel.hidden != panel.file` in every built-in theme. + +### D3: Precedence is a single ordered match + +`style = drag_target ?? cursor ?? selected ?? (hidden → panel.hidden) ?? (dir → panel.directory | panel.file)`. Hidden directories lose their bright-white directory style in favor of `panel.hidden` (uppercase/marker glyphs unchanged), so `.git` reads as "hidden" rather than "important". The git marker column keeps its own roles. + +## Risks / Trade-offs + +- [`bright-black` on `blue` (nc-classic) is low contrast on some palettes] → That is the intended "dimmed" look; the value can be tuned per theme without renderer changes. +- [Adding a role grows every theme table by one line] → No user theme loader exists today, so no external file breaks. + +## Open Questions + +- None. diff --git a/openspec/changes/hidden-entry-styling/proposal.md b/openspec/changes/hidden-entry-styling/proposal.md new file mode 100644 index 0000000..eb24583 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/proposal.md @@ -0,0 +1,32 @@ +# Change: hidden-entry-styling + +## Why + +Listings show every entry, including Windows hidden and system files and directories (`.git`, `desktop.ini`, `$RECYCLE.BIN`, `System Volume Information`), styled exactly like ordinary entries. `RawDirEntry` keeps no attribute bits even though the enumeration metadata already carries them (`fs.rs` reads `file_attributes()` for readonly/reparse). Users cannot tell at a glance which entries are the ones Explorer hides. The user chose to keep showing them but in a distinct color — no toggle. + +## What Changes + +- `Entry` gains `hidden: bool`, set from the enumeration: on Windows when `FILE_ATTRIBUTE_HIDDEN` or `FILE_ATTRIBUTE_SYSTEM` is set; elsewhere when the name starts with `.`. `..` is never hidden. +- A new theme role **`panel.hidden`** is added to every built-in theme with a visibly dimmer color than `panel.file` on the same background; hidden files and hidden directories both render with it in Full and Brief modes. +- Precedence: drag-target highlight > cursor bar > selected > hidden > directory/file (the first two in the order the renderer already uses). Sorting, filtering, selection, the mini-status, Tree mode, Quick view, and git markers are unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `theme-system`: new requirement "`panel.hidden` role" (present in every built-in theme; validation; per-theme values). +- `panel-navigation`: "Entry row rendering" adds hidden styling and precedence. +- `additional-panel-modes`: "Brief display mode" states hidden styling applies in Brief too. + +## Impact + +- `crates/filecommand-core/src/listing.rs` — `RawDirEntry.hidden`, `Entry.hidden`, `StdFsReader::read_dir` reads the attribute bits (`cfg(windows)`) or the dot prefix. +- `crates/filecommand-core/src/theme.rs` — `Role::PanelHidden` (`"panel.hidden"`), `ALL_ROLES`, six built-in tables. +- `crates/filecommand-tui/src/views/panel.rs` — entry style resolution in Full and Brief. +- Tests: listing attribute mapping (fake reader), theme tables, a snapshot with hidden entries. +- Non-breaking; **note** that `ALL_ROLES` validation means any future user theme file must define `panel.hidden`. +- **Overlap:** `full-mode-column-widths` also carries a MODIFIED "Entry row rendering" delta; whichever lands second re-bases its text on the other's merged requirement. diff --git a/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md b/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md new file mode 100644 index 0000000..256493e --- /dev/null +++ b/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Brief display mode + +The panel SHALL provide a Brief display mode that lists entry names only, arranged in `max(1, floor(interior_width / 12))` columns across the panel width — where `interior_width` is the panel's width inside its border — with the division remainder given to the last column and no Size/Date/Time columns. At the 80×24 nominal size with the default split this yields exactly three columns. Column alignment MUST use display width (`unicode-width`) so that CJK, emoji, and wide-glyph names stay aligned, and entries MUST retain the standard directory/file/hidden/selection/cursor styling and the `▶UP--DIR◀` marker for `..`, with the same style precedence as Full mode (`panel-navigation` "Entry row rendering"). + +#### Scenario: Brief mode renders three columns at the nominal size + +- **WHEN** the panel is in Brief display mode at terminal size 80×24 with the default 50/50 split (interior width 38) +- **THEN** entry names are laid out across exactly three columns of widths 12, 12, and 14, and no size, date, or time fields are shown for any entry + +#### Scenario: Wider panels earn more columns + +- **WHEN** the panel is in Brief mode with an interior width of 60 +- **THEN** entry names are laid out across five columns + +#### Scenario: Narrow panels drop to fewer columns + +- **WHEN** the panel is in Brief mode with an interior width of 23 +- **THEN** entry names are laid out in a single column spanning the interior width + +#### Scenario: Brief mode aligns wide-character names by display width + +- **WHEN** the panel is in Brief mode and a column contains a name with CJK or emoji characters +- **THEN** the following column starts at the same screen offset for every row, using `unicode-width` display width rather than byte or `char` count + +#### Scenario: Brief mode preserves entry styling and parent marker + +- **WHEN** the panel is in Brief mode and contains directories, files, a selected entry, and the `..` parent entry +- **THEN** directories render in the directory style, files in the file style, selected entries in the selected style, the cursor as a full-width inverse bar, and `..` renders as `▶UP--DIR◀` + +#### Scenario: Brief mode dims hidden entries + +- **WHEN** a panel in Brief mode lists a hidden file and a hidden directory +- **THEN** both names render with the `panel.hidden` role, with the same precedence against cursor and selection as in Full mode diff --git a/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md b/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md new file mode 100644 index 0000000..630c917 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Entry row rendering + +The system SHALL render entry rows so directories appear in the bright-white directory style and files in the file style, with `▶UP--DIR◀` shown for the `..` entry and `▶SUB-DIR◀` shown in the Size column for directories. Entries flagged hidden — on Windows those carrying the hidden or system attribute, elsewhere names beginning with `.` — SHALL render their name with the `panel.hidden` role instead of the file or directory style; the `..` entry is never hidden. Style precedence SHALL be: drag-target highlight, then cursor bar, then selected, then hidden, then directory/file. The entry under the cursor SHALL render as a full-width inverse bar using the `panel.cursor` role. Rendering SHALL use only ANSI-16 named color roles and CP437 box-drawing/geometric glyphs, with no emoji, Nerd Font, or file-type icons. + +#### Scenario: Directory entry styling + +- **WHEN** an entry is a directory +- **THEN** its name SHALL render in the `panel.directory` style and its Size column SHALL read `▶SUB-DIR◀` + +#### Scenario: Parent entry styling + +- **WHEN** the entry list contains the parent `..` entry +- **THEN** it SHALL render as `▶UP--DIR◀` in the `panel.directory` style + +#### Scenario: Hidden entries render dimmed + +- **WHEN** a directory listing contains `desktop.ini` (hidden attribute) and `.git` (hidden directory) +- **THEN** both names render with the `panel.hidden` role, `.git` still shows `▶SUB-DIR◀`, and neither entry's position in the sorted listing changes + +#### Scenario: Selected hidden entry shows selected style + +- **WHEN** a hidden file is selected with Ins +- **THEN** its row renders with the `panel.selected` role, not `panel.hidden` + +#### Scenario: Cursor bar wins + +- **WHEN** the panel cursor is on a hidden entry +- **THEN** that row SHALL render as the full-width `panel.cursor` inverse bar + +#### Scenario: Cursor row is an inverse bar + +- **WHEN** an entry is under the panel cursor +- **THEN** that row SHALL render as a full-width inverse bar using the `panel.cursor` role diff --git a/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md b/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md new file mode 100644 index 0000000..37536c6 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: `panel.hidden` role + +The theme model SHALL include a `panel.hidden` role used for entries flagged hidden or system. Every built-in theme SHALL define it with a mandatory ANSI-16 pair that is visibly dimmer than, and distinct from, that theme's `panel.file` role on the same background, with an optional truecolor override; a theme lacking the role SHALL fail validation like any other missing role. + +#### Scenario: Built-in themes define the role +- **WHEN** any built-in theme (`nc-classic`, `nc-mono`, `terminal-green`, `purple-lights`, `yellow-storm`, `inverted`) is loaded +- **THEN** `panel.hidden` resolves to a defined color pair that differs from the theme's `panel.file` pair + +#### Scenario: nc-classic hidden color +- **WHEN** `nc-classic` is active and a hidden file entry is drawn +- **THEN** its name renders bright-black on blue + +#### Scenario: Missing role is rejected +- **WHEN** a theme table omits `panel.hidden` +- **THEN** theme validation fails naming `panel.hidden` diff --git a/openspec/changes/hidden-entry-styling/tasks.md b/openspec/changes/hidden-entry-styling/tasks.md new file mode 100644 index 0000000..262e88a --- /dev/null +++ b/openspec/changes/hidden-entry-styling/tasks.md @@ -0,0 +1,26 @@ +# Tasks: hidden-entry-styling + +## 1. Listing (`crates/filecommand-core/src/listing.rs`) + +- [ ] 1.1 `RawDirEntry.hidden`, `Entry.hidden` (`Entry::parent_dir()` → `false`); `From` copies it (panel-navigation "Entry row rendering": "Hidden entries render dimmed") +- [ ] 1.2 `StdFsReader::read_dir`: `cfg(windows)` attribute test (`0x2 | 0x4`), else dot-prefix (design D1) +- [ ] 1.3 Tests: fake reader entries with the flag survive sorting and quick-filter narrowing; `..` is never hidden + +## 2. Theme (`crates/filecommand-core/src/theme.rs`) + +- [ ] 2.1 `Role::PanelHidden` → `"panel.hidden"`; add to `ALL_ROLES`; six built-in tables per design D2 (theme-system "`panel.hidden` role") +- [ ] 2.2 Tests: every built-in defines it; it differs from `panel.file` in every theme; `Theme::build` rejects a table missing it (scenario "Missing role is rejected") + +## 3. Renderer (`crates/filecommand-tui/src/views/panel.rs`) + +- [ ] 3.1 Full and Brief entry style resolution per design D3 (scenarios "Selected hidden entry shows selected style", "Cursor bar wins"; additional-panel-modes "Brief mode dims hidden entries") +- [ ] 3.2 Snapshot test: a listing with a hidden file, a hidden directory, a selected hidden file, and the cursor on a hidden entry (Full and Brief) + +## 4. Docs + +- [ ] 4.1 README "Themes": `panel.hidden` in the role list; "Panel display modes": hidden/system entries render dimmed + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `openspec validate hidden-entry-styling --strict` clean +- [ ] 5.2 Manual: browse `C:\` (`$RECYCLE.BIN`, `System Volume Information` dimmed) and a repo (`.git` dimmed) in each built-in theme From ae80023861192778ecc2fc428160e0361c222a8a Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:19 -0600 Subject: [PATCH 08/13] Add OpenSpec proposal pulldown-menu-completion Wire the greyed pull-down items that already have backing features (View, Edit, Filter, New/Close tab), add the small missing ones (Alt+F8 history dialog, Ctrl+U swap panels, Menu file edit with reload), and remove Attributes, Compare directories, Configuration, Editor selection, and Save setup. Co-Authored-By: Claude Fable 5.1 --- .../pulldown-menu-completion/.openspec.yaml | 2 + .../pulldown-menu-completion/design.md | 53 +++++++++++++++++++ .../pulldown-menu-completion/proposal.md | 35 ++++++++++++ .../specs/command-line/spec.md | 17 ++++++ .../specs/panel-navigation/spec.md | 21 ++++++++ .../specs/pulldown-menus/spec.md | 47 ++++++++++++++++ .../specs/user-menu/spec.md | 13 +++++ .../changes/pulldown-menu-completion/tasks.md | 31 +++++++++++ 8 files changed, 219 insertions(+) create mode 100644 openspec/changes/pulldown-menu-completion/.openspec.yaml create mode 100644 openspec/changes/pulldown-menu-completion/design.md create mode 100644 openspec/changes/pulldown-menu-completion/proposal.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/command-line/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/tasks.md diff --git a/openspec/changes/pulldown-menu-completion/.openspec.yaml b/openspec/changes/pulldown-menu-completion/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/pulldown-menu-completion/design.md b/openspec/changes/pulldown-menu-completion/design.md new file mode 100644 index 0000000..b8e786f --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/design.md @@ -0,0 +1,53 @@ +# Design: pulldown-menu-completion + +## Context + +`menu.rs:150-215` item tables; `update.rs:2406-2427` `MenuAction → Command`; `MenuId::target_side` resolves Left/Right menus to their own side. `OpenTab`/`CloseTab`/`QuickFilterStart` act on `state.active`. `state.history` is the persisted command list (newest last, 200 cap). `handle_request_editor` opens the cursor entry via `Effect::OpenEditor{path}` (built-in) or `Effect::RunExternalEditor(EditorInvocation, PanelSide)` (external); both are built from a path, so a menu-file edit is one new command that supplies the user-menu path instead of the cursor entry. `startup_warning` is a dismissable dialog usable mid-session. In-flight Info/git replies carry a `PanelSide` and a request id; listing chunks carry only the `PanelSide`. The `F(8)` keymap arm has no modifier guard, so Alt+F8 currently opens the delete confirmation. + +## Goals / Non-Goals + +**Goals:** + +- No greyed item left. +- Each new feature minimal and NC-faithful (Alt+F8 history, Ctrl+U swap). +- Left/Right menu items act on their own side. + +**Non-Goals:** + +- Attributes dialog, directory compare, a configuration dialog, editor selection UI, "Save setup" (settings already persist on change). +- Executing a history entry directly from the dialog (Enter places it; the user presses Enter again to run — one extra key, no accidental runs). + +## Decisions + +### D1: Side-carrying commands for the Left/Right items + +`Command::OpenTab(PanelSide)`, `CloseTab(PanelSide)`, `QuickFilterStart(PanelSide)`; the keymap passes `state.active`. For the quick filter, typing must reach the filtered panel, so `QuickFilterStart(side)` also makes `side` the active panel (a visible but expected focus change, as with Left → Info today via `ToggleInfoMode(side)`). + +Alternative rejected: keeping side-less commands and switching the active panel before dispatch — it would leave the keyboard focus moved after New tab/Close tab too, where nothing requires it. + +### D2: History dialog is a list overlay like the user menu + +`HistoryDialogState { cursor }` over `state.history` rendered newest-first; Up/Down/Home/End/PgUp/PgDn; Enter → `command_line = entry` (caret at end, `history_cursor = None`), close; Esc → close. Empty history renders a single `(no history)` row and Enter does nothing. Opened by Alt+F8 or Commands → History. Not mouse-enabled (joins the unsupported-overlay list in mouse-input). + +### D3: Swap panels exchanges `PanelState`s and re-issues async queries + +`std::mem::swap(&mut state.left, &mut state.right)`; `active` and `split_percent` unchanged (the left slot stays the left slot). Listing chunks are addressed only by `PanelSide` with no request id, so the swap is **refused while either panel's listing is streaming** (no-op; the mini-status already shows `Reading…`). Info/git replies do carry a request id, so after a swap the reducer re-issues `QueryInfo` (if in Info mode) and `QueryGitInfo` for both sides with fresh ids and any reply from before the swap is discarded as stale. Tree and Quick view modes keep working unchanged because both sides moved together. Drag state cannot exist while a menu is open; Ctrl+U during a drag is ignored. + +Alternative rejected: re-targeting in-flight replies by remembering a swap generation — more state for a case the streaming guard removes. + +### D4: Menu file edit reuses the F4 path and reloads on return + +`Command::EditUserMenuFile` → `Effect::OpenEditor{path: state.user_menu_path}` (built-in) or `RunExternalEditor` when `editor =` is set — the same branch `handle_request_editor` takes. The TUI, on editor close/return for that path, calls `config::load_user_menu` and sends `Command::UserMenuReloaded(result)`: entries replace `state.user_menu_entries`; a malformed file raises the existing warning dialog and keeps the previous entries, never overwriting the file (user-menu "Create and recover"). + +### D5: Remove, don't hide + +The five removed items disappear from the tables; Options becomes `Themes` alone (no separator). `MenuAction::Unimplemented` is retained for future proposals and the disabled-rendering unit tests build a local fixture menu instead of relying on shipped items. + +## Risks / Trade-offs + +- [Changing `OpenTab`/`CloseTab`/`QuickFilterStart` signatures touches existing tests] → Mechanical: every call site passes `state.active`. +- [A swap right after a job completes may race the automatic re-read] → Covered by D3's streaming guard; a swap while a job dialog is up cannot happen (the modal blocks menus and keys). + +## Open Questions + +- None. diff --git a/openspec/changes/pulldown-menu-completion/proposal.md b/openspec/changes/pulldown-menu-completion/proposal.md new file mode 100644 index 0000000..13508ee --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/proposal.md @@ -0,0 +1,35 @@ +# Change: pulldown-menu-completion + +## Why + +Thirteen pull-down items render greyed out (`MenuAction::Unimplemented` in `menu.rs`): Left/Right **Filter, New tab, Close tab**; Files **View, Edit, Attributes**; Commands **History, Swap panels, Compare directories, Menu file edit**; Options **Configuration, Editor selection, Save setup**. Eight of them front features that already exist (F3, F4, Ctrl+P, Ctrl+T, Ctrl+W) or are a few dozen lines away (a history list over `state.history`, a swap of two `PanelState`s, opening `usermenu.toml` in the editor). Greyed items in a shipping menu read as broken, and the pull-downs are the main discovery surface for keyboard users. The user chose to wire the cheap items and remove the rest. + +## What Changes + +- **Wired to existing commands:** Files → View (F3), Edit (F4); Left/Right → Filter (quick filter on that panel), New tab (Ctrl+T), Close tab (Ctrl+W), each acting on the menu's own panel side. +- **New, small:** Commands → **History** (new `Alt+F8` command-history dialog: pick an entry, Enter places it on the command line), **Swap panels** (new `Ctrl+U`: exchanges the two panels' entire state), **Menu file edit** (opens `usermenu.toml` with the F4 editor path and reloads the user menu on return). +- **Removed from the menus:** Files → Attributes; Commands → Compare directories; Options → Configuration, Editor selection, Save setup (Options now lists Themes only). They can return via their own proposals. +- The disabled-item rendering rule stays in the spec and renderer for future use; no shipped item is disabled after this change, so the "Not-yet-available feature renders disabled" scenario under "Menu contents" is dropped. +- Alt+F8 today falls through to the unguarded `F(8)` arm and opens the delete confirmation; the new Alt+F8 arm takes precedence, exactly as `F(7) if alt` does for find-file. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `pulldown-menus`: "Menu contents" rewritten to the final item lists; new requirement "Every listed item is wired" with per-item routing scenarios. +- `command-line`: new requirement "Command history dialog (Alt+F8)". +- `panel-navigation`: new requirement "Swap panels (Ctrl+U)". +- `user-menu`: new requirement "Edit the user menu file from the Commands menu". + +## Impact + +- `crates/filecommand-core/src/menu.rs` — item tables and `MenuAction` variants (`View`, `Edit`, `QuickFilter`, `NewTab`, `CloseTab`, `History`, `SwapPanels`, `EditMenuFile`); removed items. +- `crates/filecommand-core/src/update.rs` — `menu_action_command` mapping; `Command::OpenTab/CloseTab/QuickFilterStart` gain a `PanelSide` (the keymap passes the active side); `Command::SwapPanels`, `Command::HistoryDialogOpen/Move/Confirm/Cancel`, `Command::EditUserMenuFile`, `Command::UserMenuReloaded(..)`; `State.history_dialog: Option`, `State.user_menu_path: PathBuf`. +- `crates/filecommand-core/src/dialogs.rs` — `HistoryDialogState` (cursor over `state.history`, newest first). +- `crates/filecommand-tui` — `input/mod.rs` Alt+F8, Ctrl+U, history-dialog keymap; `views/history_dialog.rs` (primary style, like the user menu); `app.rs` reload of the user menu after the editor returns for the menu file; mouse: the history dialog is an unsupported overlay like the user menu. +- Tests across `menu.rs`, `update/tests.rs`, `input/tests.rs`, snapshots. +- Depends softly on `config-profile-directory` for the user-menu path (works with whichever path the app resolves). diff --git a/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md b/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md new file mode 100644 index 0000000..b844c91 --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Command history dialog (Alt+F8) + +The system SHALL open a modal command-history dialog on Alt+F8 or Commands → History, listing the persisted command history newest first in the primary dialog style. Up/Down/Home/End/PgUp/PgDn SHALL move the highlight; Enter SHALL replace the command-line buffer with the highlighted entry (caret at the end, no history recall in progress) and close the dialog without running anything; Esc SHALL close it unchanged. An empty history SHALL show a single `(no history)` row on which Enter does nothing. The dialog SHALL NOT respond to the mouse (it joins the unsupported-overlay set). + +#### Scenario: Pick an entry +- **WHEN** the history holds `cd C:\work` then `cd D:\` and the user presses Alt+F8, Down, Enter +- **THEN** the dialog closes and the buffer reads `cd C:\work` with the caret at the end; nothing has run + +#### Scenario: Esc leaves the buffer alone +- **WHEN** the buffer holds `cd x` and the user opens the dialog and presses Esc +- **THEN** the dialog closes and the buffer still reads `cd x` + +#### Scenario: Empty history +- **WHEN** the history is empty and the user presses Alt+F8, then Enter +- **THEN** the dialog shows `(no history)` and Enter leaves the buffer unchanged with the dialog open until Esc diff --git a/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md b/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md new file mode 100644 index 0000000..7bb0425 --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Swap panels (Ctrl+U) + +The system SHALL exchange the left and right panels' complete state — directory, tabs, listing, cursor, scroll offset, selection, sort mode, filter, display mode, and git info — on Ctrl+U or Commands → Swap panels, keeping the active side and the split percentage unchanged, without re-reading either directory. A swap requested while either panel's listing is still streaming SHALL be ignored. After a swap the system SHALL re-issue each panel's pending Info-mode and git-info queries so no reply from before the swap is applied to the wrong panel. + +#### Scenario: Swap keeps focus on the same side +- **WHEN** the left panel is active showing `C:\a` and the right shows `D:\b`, and the user presses Ctrl+U +- **THEN** the left panel shows `D:\b`, the right shows `C:\a`, the left panel is still active, and the command-line prompt reads `D:\b>` + +#### Scenario: Swap carries tabs and selection +- **WHEN** the right panel has three tabs and two selected entries and the user presses Ctrl+U +- **THEN** the left panel now has those three tabs and the same two selected entries + +#### Scenario: Swap refused while reading +- **WHEN** the right panel's listing is still streaming and the user presses Ctrl+U +- **THEN** nothing changes + +#### Scenario: Git info follows the swap +- **WHEN** the left panel is inside a git repository and the user presses Ctrl+U +- **THEN** the right panel's border shows that repository's branch suffix after its re-issued query resolves, and the left panel shows none diff --git a/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md b/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md new file mode 100644 index 0000000..707d9ab --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Menu contents + +The system SHALL populate the five menus with their defined items: Left and Right SHALL each offer Brief, Full, Tree, Quick view, Info, a separator, Name, Extension, Modif. time, Size, Unsorted, a separator, Filter, Re-read, Drive select, a separator, New tab, Close tab (mirroring each other); Files SHALL offer View, Edit, Copy, Rename/Move, Make directory, Delete, a separator, Copy to clipboard, Copy path(s), Copy name(s), a separator, Select group, Deselect group, Invert selection, a separator, Quit; Commands SHALL offer Find file, History, Swap panels, Panels on/off, Fuzzy jump, Menu file edit; Options SHALL offer Themes. Every listed item SHALL be enabled; the disabled-item rendering rule ("Pull-down visuals with separators and disabled items") remains available for future items but no shipped item uses it. + +#### Scenario: Files menu lists its items + +- **WHEN** the user opens the `Files` menu +- **THEN** the pull-down lists View, Edit, Copy, Rename/Move, Make directory, Delete, a separator, Copy to clipboard, Copy path(s), Copy name(s), a separator, Select group, Deselect group, Invert selection, a separator, and Quit, all enabled + +#### Scenario: Left and Right menus mirror each other + +- **WHEN** the user opens the `Left` menu and then the `Right` menu +- **THEN** both list the same display-mode, sort-mode, Filter, Re-read, Drive select, New tab, and Close tab items, all enabled + +#### Scenario: Commands and Options menus + +- **WHEN** the user opens `Commands` and then `Options` +- **THEN** Commands lists Find file, History, Swap panels, Panels on/off, Fuzzy jump, Menu file edit; Options lists Themes; none is disabled + +#### Scenario: Removed items are absent + +- **WHEN** the user opens every menu +- **THEN** no menu lists Attributes, Compare directories, Configuration, Editor selection, or Save setup + +## ADDED Requirements + +### Requirement: Every listed item is wired + +Activating a pull-down item SHALL perform the same action as its keyboard equivalent, applied to the menu's target panel (Left/Right menus act on their own panel; Files, Commands, Options act on the active panel): Files → View and Edit SHALL follow the F3 and F4 paths; Left/Right → Filter SHALL start the quick filter on that panel (making it the active panel so typed characters narrow it); Left/Right → New tab and Close tab SHALL open or close a tab on that panel; Commands → History SHALL open the command-history dialog; Commands → Swap panels SHALL swap the panels; Commands → Menu file edit SHALL open the user-menu file in the editor. + +#### Scenario: Files → View opens the viewer +- **WHEN** the cursor is on `notes.txt` and the user activates Files → View +- **THEN** the menu closes and the F3 viewer opens on `notes.txt` + +#### Scenario: Right → Filter on the inactive panel +- **WHEN** the left panel is active and the user activates Right → Filter +- **THEN** the right panel becomes active with its quick filter open, and subsequent typed characters narrow the right panel + +#### Scenario: Left → New tab on the inactive panel +- **WHEN** the right panel is active and the user activates Left → New tab +- **THEN** the left panel gains a new tab exactly as Ctrl+T would have created with the left panel active + +#### Scenario: Commands → Menu file edit +- **WHEN** the user activates Commands → Menu file edit +- **THEN** the user-menu file opens in the F4 editor path (external editor when configured, else the built-in editor) diff --git a/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md b/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md new file mode 100644 index 0000000..9a7ff2c --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Edit the user menu file from the Commands menu + +The system SHALL open `usermenu.toml` (at the path the application resolved at startup) when the user activates Commands → Menu file edit, using the F4 editor path — the configured external editor when `editor =` is set, otherwise the built-in editor — and SHALL reload the user menu when the editor closes or returns. A well-formed file SHALL replace the F2 entries immediately; a malformed file SHALL raise the dismissable warning dialog and keep the previous entries, and SHALL NOT overwrite the file. + +#### Scenario: Added entry appears in F2 +- **WHEN** the user activates Commands → Menu file edit, appends a valid `[[entry]]`, saves, and leaves the editor +- **THEN** pressing F2 lists the new entry + +#### Scenario: Malformed edit warns without overwriting +- **WHEN** the user leaves the editor with invalid TOML in the file +- **THEN** a warning dialog names the parse error, F2 still shows the previous entries, and the file on disk is the user's edited text diff --git a/openspec/changes/pulldown-menu-completion/tasks.md b/openspec/changes/pulldown-menu-completion/tasks.md new file mode 100644 index 0000000..a6e8b2f --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/tasks.md @@ -0,0 +1,31 @@ +# Tasks: pulldown-menu-completion + +## 1. Menu tables (`crates/filecommand-core/src/menu.rs`) + +- [ ] 1.1 Add `MenuAction::{View, Edit, QuickFilter, NewTab, CloseTab, History, SwapPanels, EditMenuFile}`; wire Left/Right Filter/New tab/Close tab, Files View/Edit, Commands History/Swap panels/Menu file edit (pulldown-menus "Every listed item is wired") +- [ ] 1.2 Remove Attributes, Compare directories, Configuration, Editor selection, Save setup; Options = `Themes` (pulldown-menus "Menu contents"; design D5) +- [ ] 1.3 Tests: item lists match the spec; no shipped item is disabled; disabled-rendering tests use a local fixture (design D5) + +## 2. Reducer (`crates/filecommand-core/src/update.rs`, `dialogs.rs`) + +- [ ] 2.1 `menu_action_command` maps the new actions; `OpenTab/CloseTab/QuickFilterStart` take `PanelSide`; `QuickFilterStart(side)` activates `side` (design D1; scenarios "Right → Filter on the inactive panel", "Left → New tab on the inactive panel") +- [ ] 2.2 `HistoryDialogState` + `Command::HistoryDialogOpen/Move/Confirm/Cancel`; Enter places the entry with the caret at the end and clears `history_cursor` (command-line "Command history dialog (Alt+F8)"; design D2) +- [ ] 2.3 `Command::SwapPanels`: streaming guard, swap, `active`/split unchanged, re-issue Info/git queries with fresh ids (panel-navigation "Swap panels (Ctrl+U)"; design D3) +- [ ] 2.4 `State.user_menu_path`; `Command::EditUserMenuFile` → editor effect for that path; `Command::UserMenuReloaded(UserMenuLoadResult)` applies entries or the malformed warning (user-menu "Edit the user menu file from the Commands menu"; design D4) +- [ ] 2.5 Tests: each menu item issues its command; history dialog navigation/confirm/cancel/empty; swap preserves the active side and split, refuses during streaming, re-queries git/Info; menu-file edit effect path and reload (well-formed and malformed) + +## 3. TUI (`crates/filecommand-tui`) + +- [ ] 3.1 `input/mod.rs`: `F(8) if alt` → `HistoryDialogOpen` placed before the plain `F(8)` arm (which has no modifier guard today); Ctrl+U → `SwapPanels`; history-dialog keymap; `OpenTab/CloseTab/QuickFilterStart` pass `state.active`; `input/tests.rs` coverage +- [ ] 3.2 `views/history_dialog.rs`: primary-style list (reuse the user-menu renderer's geometry); snapshot tests (populated, empty) +- [ ] 3.3 `app.rs`: set `user_menu_path`; after the built-in editor closes or the external editor returns on that path, reload and send `UserMenuReloaded` +- [ ] 3.4 Mouse: the history dialog is added to the unsupported-overlay set (no clicks); verify existing mouse tests + +## 4. Docs + +- [ ] 4.1 README "Pull-down menus (F9)" rewritten; keyboard reference gains Alt+F8 and Ctrl+U; F1 Help "Menus" and "Keyboard reference" pages + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `openspec validate pulldown-menu-completion --strict` clean +- [ ] 5.2 Manual: every F9 item does something; Alt+F8 → pick → Enter → Enter runs; Ctrl+U in a git repo swaps and the branch suffix follows; Commands → Menu file edit, add an entry, save → F2 shows it From c040292e3024cd18cde11f8a0419cffcf13234d2 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:19 -0600 Subject: [PATCH 09/13] Add OpenSpec proposal open-with-associated-app Add an Open entry to the file-action menu (first and highlighted for documents, after Run for executables, Explorer for directories) that launches the target through the platform file association without suspending the TUI and reports failures on the panel status line. Co-Authored-By: Claude Fable 5.1 --- .../open-with-associated-app/.openspec.yaml | 2 + .../open-with-associated-app/design.md | 50 +++++++ .../open-with-associated-app/proposal.md | 30 +++++ .../specs/file-action-menu/spec.md | 122 ++++++++++++++++++ .../changes/open-with-associated-app/tasks.md | 23 ++++ 5 files changed, 227 insertions(+) create mode 100644 openspec/changes/open-with-associated-app/.openspec.yaml create mode 100644 openspec/changes/open-with-associated-app/design.md create mode 100644 openspec/changes/open-with-associated-app/proposal.md create mode 100644 openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md create mode 100644 openspec/changes/open-with-associated-app/tasks.md diff --git a/openspec/changes/open-with-associated-app/.openspec.yaml b/openspec/changes/open-with-associated-app/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/open-with-associated-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/open-with-associated-app/design.md b/openspec/changes/open-with-associated-app/design.md new file mode 100644 index 0000000..b0c61cf --- /dev/null +++ b/openspec/changes/open-with-associated-app/design.md @@ -0,0 +1,50 @@ +# Design: open-with-associated-app + +## Context + +`FileActionMenuState::open(target, is_dir, executable, selection_scoped)` builds the entry list; `update.rs:2691-2701` routes Run to `Effect::RunShellCommand` (suspends the TUI, waits for a key) and View/Edit to the F3/F4 handlers. `Effect`s are executed in `app.rs`; failures come back as `Command`s that set `panel.last_error` (the pattern `EditorOpenFailed` uses). `clipboard-export` already strips `\\?\` before handing paths to Win32 (its "Long-path prefix is stripped" scenario under "Windows file-object payload"). `windows-sys` is only a transitive dependency today. + +## Goals / Non-Goals + +**Goals:** + +- One keystroke pair (Enter, Enter) to open a document in its app. +- No terminal takeover. +- Clear failure text. +- Explorer for directories. + +**Non-Goals:** + +- Choosing the application ("Open with…"). +- Waiting for the app and re-reading the panel. +- Associations configurable in FileCommand. +- Replacing Run (console programs still need the suspended path so their output is visible). + +## Decisions + +### D1: `ShellExecuteW` on Windows, detached spawn elsewhere + +`ShellExecuteW(NULL, L"open", path, NULL, cwd, SW_SHOWNORMAL)` via `windows-sys`; a return value ≤ 32 is an error code: `SE_ERR_NOASSOC` (31) → `No application is associated with `; `ERROR_FILE_NOT_FOUND` (2) → ` not found`; anything else → `Could not open (error )`. Non-Windows: `xdg-open` (Linux/BSD) or `open` (macOS) with stdio set to null and no wait; a spawn error maps to the same failure command. The TUI is never suspended, so a GUI app simply appears beside the terminal. + +Alternative rejected: `cmd /C start "" ""` through the existing shell path — it suspends the TUI, waits for a key, and `start`'s quoting rules are a known trap. + +### D2: Entry order and default highlight + +Non-executable file: `Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete` (Open highlighted). Executable: `Run, Open, View, …` (Run highlighted). Directory (right-click): `Open, Send to clipboard, Copy, Rename, Move, Delete`. Hotkey `O` is unique; `R` keeps resolving to Run when listed, else Rename. + +### D3: Single-target, no selection scope, no re-read + +Like Run, Open acts on the menu's target only, even when the target is selected. Because the launch is fire-and-forget, no panel re-read is scheduled; Ctrl+R remains the user's refresh. + +### D4: Console-application associations + +If a file type is associated with a console program (e.g. `.py` → `python.exe`), ShellExecute attaches it to our console and its output interleaves with the TUI. Documented in the README as the reason Run exists; not mitigated in this change. + +## Risks / Trade-offs + +- [New compile-time dependency on `windows-sys` (cfg(windows) only)] → Small, widely used, no runtime cost. +- [Default highlight moves from View to Open] → Muscle memory of "Enter, Enter = View" changes once; user-approved. + +## Open Questions + +- None. diff --git a/openspec/changes/open-with-associated-app/proposal.md b/openspec/changes/open-with-associated-app/proposal.md new file mode 100644 index 0000000..973ee0b --- /dev/null +++ b/openspec/changes/open-with-associated-app/proposal.md @@ -0,0 +1,30 @@ +# Change: open-with-associated-app + +## Why + +Enter on a document opens the file-action menu, whose entries are View, Edit, Send to clipboard, Copy, Rename, Move, Delete — and Run for executables only. There is no way to open a `.pdf`, `.png`, `.docx`, or `.xlsx` in the application Windows associates with it; View shows bytes and Edit opens a text editor. For a Windows-first file manager this is the most-used missing action. Norton Commander covered the same need through its extension file; the modern equivalent is `ShellExecute` with the `open` verb. + +## What Changes + +- A new **Open** entry in the file-action menu: first and highlighted for non-executable files (so Enter, Enter opens a document); for executables Run stays first and Open is second; for directory targets (right-click menu) Open is first and opens the directory in the system file browser. Hotkey `O`. +- Open launches the target through the platform's file association **without suspending the TUI** and without waiting: `ShellExecuteW(open)` on Windows, `xdg-open`/`open` detached elsewhere. The panel stays interactive. +- Failure (no association, launch error) is reported on the active panel's status line (`last_error`), e.g. `No application is associated with report.xyz`. +- Open is always single-target (like Run) and never touches the selection. Run, View, Edit, and every other entry are unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `file-action-menu`: "Menu contents, ordering, and navigation" (Open first, hotkey `O`), "Menu actions route to existing flows" (Open routing), "Directory targets and selection-scoped invocation" (Open first for directories), plus a new requirement "Open with the associated application" (launch semantics, non-blocking, failure reporting). + +## Impact + +- `crates/filecommand-core/src/dialogs.rs` — `FileActionMenuEntry::Open`, ordering, label/hotkey; `update.rs` — `Command::FileActionMenuConfirm` routing to `Effect::OpenWithAssociation{path, cwd}`; `Command::OpenWithAssociationFailed{message}` → `last_error`. +- `crates/filecommand-tui/src/app.rs` — effect handler; new `cfg(windows)` dependency `windows-sys` (features `Win32_UI_Shell`, `Win32_Foundation`, `Win32_UI_WindowsAndMessaging`) in `crates/filecommand-tui/Cargo.toml`; non-Windows `std::process::Command` detached spawn. +- Paths handed to the shell API are stripped of the `\\?\` verbatim prefix the same way `clipboard-export` does (its "Long-path prefix is stripped" scenario). +- Tests: menu contents/hotkeys, routing effect, failure message; snapshot of the menu with Open first. +- Non-breaking for keys; **changes the default highlighted entry** from View to Open for non-executables (user decision). diff --git a/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md b/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md new file mode 100644 index 0000000..fdeb58d --- /dev/null +++ b/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md @@ -0,0 +1,122 @@ +## MODIFIED Requirements + +### Requirement: Menu contents, ordering, and navigation + +The file-action menu SHALL list the entries Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete in that order for a non-executable file, and SHALL list Run first (before Open) when the target is executable (PATHEXT match or `.lnk`). The menu SHALL render as a primary-style modal dialog (§4.4) with the first entry highlighted on open. Up/Down SHALL move the highlight, Enter SHALL activate the highlighted entry and close the menu, Esc SHALL close the menu with no action taken, and pressing an entry's first letter SHALL activate that entry directly (`O` resolves to Open; `R` resolves to Run when Run is listed, otherwise to Rename). Rendering SHALL use only ANSI-16 named color roles and CP437 glyphs. + +#### Scenario: Non-executable menu contents + +- **WHEN** the menu opens for `notes.txt` +- **THEN** it lists Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete in that order with Open highlighted + +#### Scenario: Executable gets Run first + +- **WHEN** the menu opens for `setup.exe` +- **THEN** it lists Run, Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete with Run highlighted +- **AND** pressing Enter immediately activates Run + +#### Scenario: Esc closes with no action + +- **WHEN** the menu is open and the user presses Esc +- **THEN** the menu closes, no action runs, and the panel cursor and selection are unchanged + +#### Scenario: First-letter hotkey activates directly + +- **WHEN** the menu is open for `notes.txt` and the user presses `D` +- **THEN** the Delete action is activated exactly as if it had been highlighted and Enter pressed + +#### Scenario: O activates Open + +- **WHEN** the menu is open for `report.pdf` and the user presses `O` +- **THEN** the Open action is activated exactly as if it had been highlighted and Enter pressed + +#### Scenario: S activates Send to clipboard + +- **WHEN** the menu is open for `notes.txt` and the user presses `S` +- **THEN** the Send to clipboard action is activated exactly as if it had been highlighted and Enter pressed + +### Requirement: Menu actions route to existing flows + +Each file-action menu entry SHALL route into the existing capability flow for that action, applied to the menu's target entry: Open SHALL launch the target through the platform file association per "Open with the associated application"; View SHALL open the F3 viewer; Edit SHALL follow the F4 edit path (external editor when configured, otherwise the built-in editor rules); Copy SHALL open the F5 destination-input dialog pre-filled with the opposite panel's path, scoped to the single target entry; Move SHALL open the F6 destination-input dialog pre-filled with the opposite panel's path, scoped to the single target entry; Delete SHALL open the F8 delete-confirmation flow for the single target entry; Send to clipboard SHALL run the `clipboard-export` Files action scoped to the single target entry; Run (executables only) SHALL use the existing suspended-TUI spawn path. Downstream behavior — overwrite conflicts, progress, error recovery, skipped-files summary, and panel re-read on completion — SHALL follow the existing `file-operations` and `operation-dialogs` requirements unchanged. + +#### Scenario: Open launches the associated application + +- **WHEN** the user activates Open for `report.pdf` +- **THEN** the menu closes, `report.pdf` is handed to the platform file association, and the panels remain interactive without any TUI suspension + +#### Scenario: View opens the viewer + +- **WHEN** the user activates View for `notes.txt` +- **THEN** the menu closes and the F3 viewer opens on `notes.txt` + +#### Scenario: Copy opens the destination dialog for the single entry + +- **WHEN** the left panel is active, the right panel shows `D:\backup`, and the user activates Copy for `report.txt` +- **THEN** the menu closes and the destination-input dialog opens pre-filled with `D:\backup` +- **AND** accepting it starts a copy job whose scope is exactly `report.txt` + +#### Scenario: Delete requires the existing confirmation + +- **WHEN** the user activates Delete for `notes.txt` +- **THEN** the menu closes and the delete-confirmation dialog names `notes.txt` and states that deletion is permanent +- **AND** declining the confirmation deletes nothing + +#### Scenario: Send to clipboard copies the single entry + +- **WHEN** the user activates Send to clipboard for `notes.txt` +- **THEN** the menu closes and the clipboard holds a file object for exactly `notes.txt` + +#### Scenario: Run spawns via the suspended-TUI path + +- **WHEN** the user activates Run for `build.bat` +- **THEN** the menu closes and `build.bat` runs via the suspended-TUI spawn path in the panel's current directory, exactly as a command-line invocation would + +### Requirement: Directory targets and selection-scoped invocation + +When the file-action menu is opened by a mouse right-click, the system SHALL allow a directory as the target, omitting View, Edit, and Run from the menu; and when the target entry is a member of the panel's selection set, Copy, Move, Delete, and Send to clipboard SHALL act on the whole selection set, with the resulting dialog naming the count. Open SHALL always act on the single target, never the selection. Enter-key invocation SHALL remain single-target and file-only as previously specified. The directory menu SHALL list its entries as Open, Send to clipboard, Copy, Rename, Move, Delete in that order, with Open highlighted; Open on a directory SHALL show that directory in the platform file browser. + +#### Scenario: Directory menu contents + +- **WHEN** the menu opens by right-click on `src` +- **THEN** it lists Open, Send to clipboard, Copy, Rename, Move, Delete in that order with Open highlighted + +#### Scenario: Open on a directory shows it in the file browser + +- **WHEN** the user activates Open for the directory `src` +- **THEN** `src` is handed to the platform file association (Explorer on Windows) and the panel does not navigate + +#### Scenario: Selection-scoped delete + +- **WHEN** three entries are selected and the user right-clicks one of them and activates Delete +- **THEN** the delete-confirmation dialog names three entries + +#### Scenario: Open ignores the selection + +- **WHEN** three entries are selected and the user right-clicks one of them and activates Open +- **THEN** only that entry is handed to the platform file association + +#### Scenario: Enter stays single-target + +- **WHEN** three entries are selected and the user presses Enter on one of them and activates Copy +- **THEN** the destination-input dialog is scoped to that single entry + +## ADDED Requirements + +### Requirement: Open with the associated application + +The system SHALL launch the Open target through the platform's file association — `ShellExecute` with the `open` verb on Windows, `xdg-open`/`open` elsewhere — with the active panel's directory as the working directory, without suspending the TUI, without waiting for the launched program, and without re-reading the panel. Paths SHALL be passed without the `\\?\` verbatim prefix. When the launch fails, the system SHALL report the reason on the active panel's status line: `No application is associated with ` when no association exists, ` not found` when the target is missing, otherwise `Could not open (error )`. + +#### Scenario: Document opens while the panels stay live + +- **WHEN** the user activates Open for `budget.xlsx` +- **THEN** the associated application starts, the terminal is not suspended, and the next key press is handled by the panels + +#### Scenario: No association is reported inline + +- **WHEN** the user activates Open for `data.zzz` and no application is associated with `.zzz` +- **THEN** the active panel's status line reads `No application is associated with data.zzz` and no dialog opens + +#### Scenario: Long path is passed without the verbatim prefix + +- **WHEN** the target's absolute path exceeds 260 characters +- **THEN** the path handed to the platform API carries no `\\?\` prefix diff --git a/openspec/changes/open-with-associated-app/tasks.md b/openspec/changes/open-with-associated-app/tasks.md new file mode 100644 index 0000000..63894b3 --- /dev/null +++ b/openspec/changes/open-with-associated-app/tasks.md @@ -0,0 +1,23 @@ +# Tasks: open-with-associated-app + +## 1. Core (`crates/filecommand-core`) + +- [ ] 1.1 `dialogs.rs`: `FileActionMenuEntry::Open` with label `Open`/hotkey `O`; ordering per design D2 for file, executable, and directory targets (file-action-menu "Menu contents, ordering, and navigation"; "Directory targets and selection-scoped invocation") +- [ ] 1.2 `update.rs`: `Effect::OpenWithAssociation { path: PathBuf, cwd: PathBuf }`; Open routes to it single-target (design D3); `Command::OpenWithAssociationFailed { message }` sets the active panel's `last_error` (file-action-menu "Open with the associated application") +- [ ] 1.3 Tests: menu order and highlight for the three target kinds; `O` hotkey; Open on a selected target emits one effect for that target only; the failure command lands on `last_error` + +## 2. TUI (`crates/filecommand-tui`) + +- [ ] 2.1 `Cargo.toml`: `[target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }` +- [ ] 2.2 `app.rs`: `open_with_association(path, cwd) -> Result<(), String>`; Windows `ShellExecuteW` with the `\\?\` prefix stripped (reuse the clipboard-export helper) and error-code mapping per design D1; non-Windows detached `xdg-open`/`open` +- [ ] 2.3 Dispatch the effect without suspending the terminal; map `Err` to `Command::OpenWithAssociationFailed` +- [ ] 2.4 Snapshot tests: file menu with Open first and highlighted; executable menu with Run then Open; directory menu with Open first + +## 3. Docs + +- [ ] 3.1 README: file-action menu entry lists (Enter and right-click), "Running programs" section explains Open vs Run (design D4); F1 Help "File operations" page + +## 4. Verify + +- [ ] 4.1 `cargo test --workspace` green; `openspec validate open-with-associated-app --strict` clean +- [ ] 4.2 Manual: Enter, Enter on a `.pdf` opens the reader while FileCommand stays live; right-click a directory → Open shows it in Explorer; a file with an unknown extension reports the no-association message on the status line From 22bada244e985984b0e334e7132ac7c7fea132af Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:25:09 -0600 Subject: [PATCH 10/13] Fix wording errors found by the proposal review help-flags: the flag is accepted anywhere, not only first. delete-key-triggers-delete: the guard is an exact no-modifier check, not is_plain. delete-builtins-reject-dot-targets: the function is dispatch_delete_builtin. reread-preserves-cursor: exclude the Tree-mode preview caller, restore TabData, handle quick-filter and Brief-mode offsets. ui-animations: --noanimations precedence is applied in run, stamp About and the startup warning, and correct the config and usage wording. Co-Authored-By: Claude Sonnet 5.5 --- .../changes/delete-builtins-reject-dot-targets/design.md | 4 ++-- .../changes/delete-builtins-reject-dot-targets/proposal.md | 2 +- .../changes/delete-builtins-reject-dot-targets/tasks.md | 2 +- openspec/changes/delete-key-triggers-delete/design.md | 4 ++-- openspec/changes/delete-key-triggers-delete/tasks.md | 2 +- openspec/changes/help-flags/design.md | 2 +- openspec/changes/help-flags/proposal.md | 2 +- openspec/changes/reread-preserves-cursor/design.md | 6 +++--- openspec/changes/reread-preserves-cursor/tasks.md | 6 +++--- openspec/changes/ui-animations/design.md | 6 +++--- openspec/changes/ui-animations/proposal.md | 4 ++-- openspec/changes/ui-animations/specs/ui-animations/spec.md | 6 +++--- openspec/changes/ui-animations/tasks.md | 4 ++-- 13 files changed, 25 insertions(+), 25 deletions(-) diff --git a/openspec/changes/delete-builtins-reject-dot-targets/design.md b/openspec/changes/delete-builtins-reject-dot-targets/design.md index 34ca561..ff86a26 100644 --- a/openspec/changes/delete-builtins-reject-dot-targets/design.md +++ b/openspec/changes/delete-builtins-reject-dot-targets/design.md @@ -1,6 +1,6 @@ ## Context -`run_delete_builtin` (`crates/filecommand-core/src/update.rs`) determines the target's file-vs-directory type in two ways: a bare name that matches a currently-listed entry answers from the loaded `EntryKind`, and anything else gets a filesystem check. The panel's listing always contains a `..` row (`EntryKind::ParentDir`, and `is_dir_like()` is true for it), so: +`dispatch_delete_builtin` (`crates/filecommand-core/src/update.rs`) determines the target's file-vs-directory type in two ways: a bare name that matches a currently-listed entry answers from the loaded `EntryKind`, and anything else gets a filesystem check. The panel's listing always contains a `..` row (`EntryKind::ParentDir`, and `is_dir_like()` is true for it), so: - `del ..` is rejected today — but only incidentally, by the type check (`ParentDir` reads as a directory). The rejection message (".. is a directory") misdescribes the real problem. - `rmdir ..` sails through: listed ⇒ typed-as-directory ⇒ delete-confirmation dialog opens naming the parent directory. Accepting it (plus the second confirmation) dispatches a real delete job against the parent. @@ -23,7 +23,7 @@ F8 and the file-action menu's Delete entry never reach this state: their scope c - **Reject via `Path::components`, not string equality.** `Path::new("..\\").components()` yields just `ParentDir` (trailing separators are normalized), so one components check covers `..`, `..\`, `.`, and `.\` — string comparison would miss the separator variants. A multi-component path like `..\sibling` is unaffected (its first component is not the whole target). - **Reject before the listed-entry lookup and the filesystem check.** Both degenerate names are pure syntax; spending an `is_dir()` stat or consulting the listing for them is wasted work and muddies which rule fired. -- **Message shape: `"{verb}: invalid target {target}"`** — the same wording `run_delete_builtin` already uses for the unresolvable-target case, keeping the verb's errors self-consistent. +- **Message shape: `"{verb}: invalid target {target}"`** — the same wording `dispatch_delete_builtin` already uses for the unresolvable-target case, keeping the verb's errors self-consistent. ## Risks / Trade-offs diff --git a/openspec/changes/delete-builtins-reject-dot-targets/proposal.md b/openspec/changes/delete-builtins-reject-dot-targets/proposal.md index 05180e7..9ef5711 100644 --- a/openspec/changes/delete-builtins-reject-dot-targets/proposal.md +++ b/openspec/changes/delete-builtins-reject-dot-targets/proposal.md @@ -22,5 +22,5 @@ Both go through the confirmation dialog, so nothing deletes instantly — but a ## Impact -- `crates/filecommand-core/src/update.rs`: `run_delete_builtin` gains one early guard rejecting `.`/`..` targets before resolution; everything after is untouched. +- `crates/filecommand-core/src/update.rs`: `dispatch_delete_builtin` gains one early guard rejecting `.`/`..` targets before resolution; everything after is untouched. - New unit tests cover both verbs against `.` and `..` (no dialog, error set, no deletion). diff --git a/openspec/changes/delete-builtins-reject-dot-targets/tasks.md b/openspec/changes/delete-builtins-reject-dot-targets/tasks.md index a1b8b62..52866c7 100644 --- a/openspec/changes/delete-builtins-reject-dot-targets/tasks.md +++ b/openspec/changes/delete-builtins-reject-dot-targets/tasks.md @@ -1,6 +1,6 @@ ## 1. Core: reject `.`/`..` delete targets -- [x] 1.1 In `crates/filecommand-core/src/update.rs`, add an early guard at the top of `run_delete_builtin`: a target whose `Path::components()` is exactly `CurDir` or `ParentDir` sets `last_error` to `"{verb}: invalid target {target}"` and returns — before any listed-entry lookup, filesystem check, or dialog. +- [x] 1.1 In `crates/filecommand-core/src/update.rs`, add an early guard at the top of `dispatch_delete_builtin`: a target whose `Path::components()` is exactly `CurDir` or `ParentDir` sets `last_error` to `"{verb}: invalid target {target}"` and returns — before any listed-entry lookup, filesystem check, or dialog. - [x] 1.2 The guard must cover trailing-separator spellings (`..`, `..\`, `.`, `.\`) via the components normalization, and must not reject multi-component relative paths like `..\sibling`. ## 2. Tests diff --git a/openspec/changes/delete-key-triggers-delete/design.md b/openspec/changes/delete-key-triggers-delete/design.md index 75ad8ac..a8f202b 100644 --- a/openspec/changes/delete-key-triggers-delete/design.md +++ b/openspec/changes/delete-key-triggers-delete/design.md @@ -24,14 +24,14 @@ The bug shipped without detection because `input/tests.rs` asserts `F(8) → Req The Delete key is a hard-coded alias for F8, mirroring the existing fixed `Ctrl+Ins` → clipboard-Files alias in the same function (which is deliberately not rebindable). This is the smallest possible surface and matches Norton Commander, where Del maps to the F8 action. Rejected: adding a `delete` field to the `Keys` config struct — more surface (schema, defaults, docs) for no requested benefit. ### D2 — Plain Delete only -The new arm is guarded by the existing `is_plain(&key)` helper so that only an unmodified Delete triggers deletion; a modified Delete press falls through, keeping `Shift+Delete` and `Ctrl+Delete` available for any future meaning. This matches how the plain F8 arm and the other unmodified panel keys behave. +The new arm is guarded by an exact no-modifier check (`key.modifiers == KeyModifiers::NONE`, deliberately stricter than `is_plain`, which still admits Shift) so that only an unmodified Delete triggers deletion; a modified Delete press falls through, keeping `Shift+Delete` and `Ctrl+Delete` available for any future meaning. This matches how the plain F8 arm and the other unmodified panel keys behave. ### D3 — Placement in the match The arm is added among the unmodified navigation/function keys near the `KeyCode::F(8)` arm, i.e. after the ctrl/alt-guarded arms earlier in the function, preserving the file's existing "guarded chords first, then plain keys" ordering. ## Risks / Tradeoffs -Negligible — the change is purely additive and reuses an already-tested command path. The only conceivable risk is shadowing a future modified-Delete chord, which the `is_plain` guard prevents. +Negligible — the change is purely additive and reuses an already-tested command path. The only conceivable risk is shadowing a future modified-Delete chord, which the no-modifier guard prevents. ## Open Questions diff --git a/openspec/changes/delete-key-triggers-delete/tasks.md b/openspec/changes/delete-key-triggers-delete/tasks.md index cae8419..1f28858 100644 --- a/openspec/changes/delete-key-triggers-delete/tasks.md +++ b/openspec/changes/delete-key-triggers-delete/tasks.md @@ -1,6 +1,6 @@ ## 1. Wire the Delete key -- [x] 1.1 In `crates/filecommand-tui/src/input/mod.rs`, add a `KeyCode::Delete if is_plain(&key) => Some(Command::RequestDelete)` arm to `map_panel_key`, placed among the unmodified keys near the existing `KeyCode::F(8)` arm. +- [x] 1.1 In `crates/filecommand-tui/src/input/mod.rs`, add a `KeyCode::Delete if key.modifiers == KeyModifiers::NONE => Some(Command::RequestDelete)` arm to `map_panel_key`, placed among the unmodified keys near the existing `KeyCode::F(8)` arm. ## 2. Regression test diff --git a/openspec/changes/help-flags/design.md b/openspec/changes/help-flags/design.md index 8dd1e96..a7a8d74 100644 --- a/openspec/changes/help-flags/design.md +++ b/openspec/changes/help-flags/design.md @@ -19,7 +19,7 @@ **D1 — Manual argv check, no parsing library** -Check `std::env::args().nth(1)` for the three flag strings (`-h`, `-?`, `--help`). The binary has no other flags today, so pulling in a full parser crate would be disproportionate. If flags grow in future the decision can be revisited. +Check `std::env::args().skip(1)` for any occurrence of the three flag strings (`-h`, `-?`, `--help`). The binary has no other flags today, so pulling in a full parser crate would be disproportionate. If flags grow in future the decision can be revisited. *Alternatives considered:* `clap` — rejected (heavy dependency for a one-flag check); `pico-args` — still a new dep, not justified yet. diff --git a/openspec/changes/help-flags/proposal.md b/openspec/changes/help-flags/proposal.md index 1db33cf..6e7ed6e 100644 --- a/openspec/changes/help-flags/proposal.md +++ b/openspec/changes/help-flags/proposal.md @@ -4,7 +4,7 @@ Users expect `-h`, `-?`, and `--help` to print usage information and exit immedi ## What Changes -- Recognise `-h`, `-?`, and `--help` as the first argument on the command line +- Recognise `-h`, `-?`, and `--help` anywhere on the command line - Print a short usage block to stdout (synopsis, flags, and a pointer to F1 in-app help) - Exit with code 0 before acquiring the terminal, alternate screen, or mouse capture diff --git a/openspec/changes/reread-preserves-cursor/design.md b/openspec/changes/reread-preserves-cursor/design.md index 31d1565..cc53ced 100644 --- a/openspec/changes/reread-preserves-cursor/design.md +++ b/openspec/changes/reread-preserves-cursor/design.md @@ -4,7 +4,7 @@ Every directory read in `filecommand-core` goes through `update::begin_listing` → `begin_listing_inner` → `Panel::begin_new_listing(cwd)` (`crates/filecommand-core/src/panel.rs`). That method resets `cursor = 0`, `scroll_offset = 0`, and `cursor_user_moved = false`, then the worker streams `Command::ListingChunk` events whose `insert_streamed` keeps the cursor pinned to 0 until the user moves (panel-navigation "Streamed listing keeps the top pinned until the user moves"). `Command::ListingComplete` clamps the cursor, reconciles the selection, consumes find-file's `pending_cursor_target` (if any) to settle the cursor on a named entry, and calls `reconcile_panel_viewport` to re-clamp the offset. -That reset is right when the panel navigates into a different directory. It is wrong for the three callers that re-read the directory the panel is already showing, all of which reach `begin_listing_inner` today: +That reset is right when the panel navigates into a different directory. It is wrong for the three callers that re-read the directory the panel is already showing, all of which reach `begin_listing_inner` today (a fourth caller, the Tree-mode cursor-move preview of the opposite panel, also reaches it and is deliberately excluded — see D5): - `Command::JobDone` — re-reads every active tab whose `cwd` matches the job's source or destination directory (file-operations "Automatic panel re-read on completion"). - `Command::RereadPanel` — Ctrl+R and the Left/Right menu's Re-read item. @@ -44,7 +44,7 @@ Find-file's `pending_cursor_target` is checked first on `ListingComplete` and wi ### D3: Restore the scroll offset, then clamp to the new list -On apply, `scroll_offset` is restored from the anchor and then clamped to `visible_len.saturating_sub(rows)` so a list that shrank at the bottom does not leave blank trailing rows (deleting the last visible entry at the end of a long listing pulls the window up by one, as Norton Commander does). The existing `reconcile_panel_viewport` then runs as it does for every other list mutation, so if the panel's body height differs from when the anchor was captured, the cursor-visibility clamp wins — the same rule panel-navigation already applies to tab restore. In Brief mode the restored offset is passed through `ensure_cursor_visible_brief`, which keeps it on a whole-column multiple. +On apply, `scroll_offset` is restored from the anchor and then clamped to `visible_len.saturating_sub(rows)` so a list that shrank at the bottom does not leave blank trailing rows (deleting the last visible entry at the end of a long listing pulls the window up by one, as Norton Commander does). The existing `reconcile_panel_viewport` then runs as it does for every other list mutation, so if the panel's body height differs from when the anchor was captured, the cursor-visibility clamp wins — the same rule panel-navigation already applies to tab restore. In Brief mode the offset is a column position, not a row position, so the rows-based clamp is not applied; the restored offset is passed through `ensure_cursor_visible_brief`, which keeps it on a whole-column multiple and clamps by columns. The offset counts rows of the quick-filter-narrowed list, and `begin_new_listing` clears the filter, so when a quick filter was active at capture the scroll offset is **not** restored (it resets to 0); the cursor entry still is, and `reconcile_panel_viewport` scrolls it into view. ### D4: The anchor lives on `Panel` and is cleared by any different-directory navigation @@ -52,7 +52,7 @@ On apply, `scroll_offset` is restored from the anchor and then clamped to `visib ### D5: Capture in `begin_listing_inner`, so every caller benefits without plumbing -`begin_listing_inner` is the single funnel for all three re-read callers and for ordinary navigation. It captures the anchor (via `Panel::capture_reread_anchor`) only when the target path equals the panel's current `cwd` **and** the current listing is `Complete`; a re-read issued while the previous listing is still streaming captures nothing and keeps today's behavior rather than anchoring to a half-loaded list. `JobDone`, `RereadPanel`, and the stale-tab path need no changes of their own. +`begin_listing_inner` is the single funnel for all three re-read callers, for ordinary navigation, and for the Tree-mode preview. The preview is a navigation of the *opposite* panel, not a re-read the user asked for, so it never captures an anchor: the capture is skipped when the caller is the Tree preview (an explicit `capture_anchor: bool` parameter on `begin_listing_inner`, `true` only from `begin_listing`'s re-read paths). It captures the anchor (via `Panel::capture_reread_anchor`) only when the target path equals the panel's current `cwd` **and** the current listing is `Complete`; a re-read issued while the previous listing is still streaming captures nothing and keeps today's behavior rather than anchoring to a half-loaded list. `JobDone`, `RereadPanel`, and the stale-tab path need no changes of their own. ## Risks / Trade-offs diff --git a/openspec/changes/reread-preserves-cursor/tasks.md b/openspec/changes/reread-preserves-cursor/tasks.md index 702ce59..e34b69d 100644 --- a/openspec/changes/reread-preserves-cursor/tasks.md +++ b/openspec/changes/reread-preserves-cursor/tasks.md @@ -3,14 +3,14 @@ ## 1. Core panel state (`crates/filecommand-core/src/panel.rs`) - [ ] 1.1 Add `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` and `Panel::reread_anchor: Option`, initialized to `None` (design D1, D4) -- [ ] 1.2 Include `reread_anchor` in the per-tab stash/restore data beside `pending_cursor_target` so a tab switched away mid-re-read keeps it (design D4) +- [ ] 1.2 Add `reread_anchor` to the `TabData` struct and to `to_tab_data` and its restore counterpart beside `pending_cursor_target`, so a tab switched away mid-re-read keeps it (design D4) - [ ] 1.3 Add `Panel::capture_reread_anchor()` — records entry names in display order, cursor, and scroll offset only when `progress` is `Complete`; otherwise leaves the anchor `None` (design D5) -- [ ] 1.4 Add `Panel::apply_reread_anchor(rows)` — resolves the cursor in the order `names[cursor]`, `names[cursor + 1..]`, `names[..cursor]` reversed; on a hit sets `cursor` and `cursor_user_moved = true`, restores `scroll_offset` and clamps it to `visible_len.saturating_sub(rows)`; returns whether a name resolved (panel-navigation: "Same-directory re-read preserves cursor and scroll position"; design D1, D3) +- [ ] 1.4 Add `Panel::apply_reread_anchor(rows)` — resolves the cursor in the order `names[cursor]`, `names[cursor + 1..]`, `names[..cursor]` reversed; on a hit sets `cursor` and `cursor_user_moved = true`, restores `scroll_offset` (skipped when a quick filter was active at capture; in Brief mode clamped by columns via `ensure_cursor_visible_brief`, otherwise to `visible_len.saturating_sub(rows)`); returns whether a name resolved (panel-navigation: "Same-directory re-read preserves cursor and scroll position"; design D1, D3) - [ ] 1.5 In `begin_new_listing(cwd)`, clear `reread_anchor` when `cwd != self.cwd` so a navigation never inherits a stale anchor (design D4) ## 2. Reducer (`crates/filecommand-core/src/update.rs`) -- [ ] 2.1 In `begin_listing_inner`, call `capture_reread_anchor()` before `begin_new_listing` when `path == panel.cwd` (design D5) +- [ ] 2.1 In `begin_listing_inner`, call `capture_reread_anchor()` before `begin_new_listing` when `path == panel.cwd` and the caller is not the Tree-mode preview at update.rs `begin_listing_inner(state, side.toggle(), target)` (design D5) - [ ] 2.2 In `apply_listing_event`, on `ListingChunk` call `apply_reread_anchor(rows)` after the chunk is inserted and before `reconcile_panel_viewport` (panel-navigation: "No jump while the re-read streams"; design D2) - [ ] 2.3 In `apply_listing_event`, on `ListingComplete` apply `pending_cursor_target` first (unchanged), then `apply_reread_anchor(rows)` if no find-file target was consumed, then take the anchor so it is consumed, then `reconcile_panel_viewport` (design D2) - [ ] 2.4 Route the Brief-mode offset restore through `ensure_cursor_visible_brief` so the restored offset stays on a whole-column multiple (design D3) diff --git a/openspec/changes/ui-animations/design.md b/openspec/changes/ui-animations/design.md index 9b6c6f7..666cc80 100644 --- a/openspec/changes/ui-animations/design.md +++ b/openspec/changes/ui-animations/design.md @@ -39,14 +39,14 @@ For the dialog iris and the panel wipe, the view renders the finished surface in - `MenuState { opened_at_ms, pulldown_opened_at_ms, selected_at_ms }` — `pulldown_opened_at_ms` is re-stamped on Left/Right traversal and hotkey jumps (the new pull-down unfolds while open and interactive); `selected_at_ms` on every selection change. - `UserMenuState { opened_at_ms, selected_at_ms }`, `FileActionMenuState { opened_at_ms, selected_at_ms }` — same unfold/settle treatment via one shared pop-up helper. -- `State::dialog_opened_at_ms` — set whenever a modal dialog becomes visible (phase enters `FileOpSetup`/`FileOpRunning`/`FileOpSummary`, or `help`/`theme_picker`/`drive_select`/`fuzzy_jump`/`find_file` become `Some`). Only one of these is visible at a time, so one stamp suffices; a replacement dialog (e.g. progress → conflict) re-stamps and irises again. +- `State::dialog_opened_at_ms` — set whenever a modal dialog becomes visible (phase enters `FileOpSetup`/`FileOpRunning`/`FileOpSummary`, or `help` (including its About dialog)/`theme_picker`/`drive_select`/`fuzzy_jump`/`find_file` become `Some`, or the startup warning becomes visible). The startup warning is set before the first `Tick`, so it is stamped from the first clock reading the reducer sees rather than at construction, and `MenuState::opened`/`for_menu`/`go_to` take the clock reading as an argument (or are stamped by the reducer immediately after construction). Only one of these is visible at a time, so one stamp suffices; a replacement dialog (e.g. progress → conflict) re-stamps and irises again. - `State::quit_opened_at_ms` — the quit dialog layers over others, so it has its own stamp and the underlying dialog does not re-animate. - `State::panels_revealed_at_ms: Option` — set on the `Splash → Panels` transition only (hold elapsed or key). `None` when the splash was skipped, so frame 1 with `--nosplash` is still the complete panels. - `UiPhase::Splash { started_at_ms }` already exists and drives the splash entrance. ### D5: `State::animations` defaults off in `State::empty`, on in `Config::default` -`Config::default().animations = true` so real launches animate; `apply_config` copies it into `State::animations`; `State::empty` sets `false`. Every view checks `state.animations` first and short-circuits to the final frame when off. Result: all existing snapshot and reducer tests (which construct `State::empty`) are unchanged; animation snapshots opt in by setting the flag and pinning `clock_ms`. +`Config::default().animations = true` so real launches animate; `apply_config` copies the config value into `State::animations`, and `run` applies the `--noanimations` override (alongside the existing `config.splash && !launch.no_splash` computation) because `apply_config` takes no launch options; `State::empty` sets `false`. Every view checks `state.animations` first and short-circuits to the final frame when off. Result: all existing snapshot and reducer tests (which construct `State::empty`) are unchanged; animation snapshots opt in by setting the flag and pinning `clock_ms`. ### D6: Fixed timing budget, all ≤ the existing splash hold @@ -65,7 +65,7 @@ Durations are constants in `core::anim` so tests reference them by name. Everyth ### D7: Disable precedence mirrors `splash`/`mouse` -`animations = false` in the general options of `config.toml`, or `--noanimations` at launch; the flag wins when they disagree. Parsed in `config::parse` next to `splash` and in `parse_launch_args` next to `--nosplash`; listed in `print_usage` and the README. The design doc §4.10 sentence is superseded for decorative chrome only; this change is the normative record of that. +`animations = false` as a top-level key of `config.toml`, or `--noanimations` at launch; the flag wins when they disagree. Parsed in `config::parse` next to `splash` and in `parse_launch_args` next to `--nosplash`; listed in `print_usage` and the README. The design doc §4.10 sentence is superseded for decorative chrome only; this change is the normative record of that. ## Risks / Trade-offs diff --git a/openspec/changes/ui-animations/proposal.md b/openspec/changes/ui-animations/proposal.md index 662de75..1faa916 100644 --- a/openspec/changes/ui-animations/proposal.md +++ b/openspec/changes/ui-animations/proposal.md @@ -12,7 +12,7 @@ The design doc (§4.10) says FileCommand "never uses spinners or animation glyph - **Startup splash entrance**: the double-line frame traces itself clockwise from the top-left corner, the four identity lines reveal outward from their centers (appearing first in the frame color, then settling to their final role), and a single highlight sweep crosses the product name — all inside the existing 800 ms minimum hold. After the splash, the panels wipe in top-to-bottom over 150 ms. - **Menus**: on F9 the menu bar sweeps in left-to-right; every pop-up menu (F9 pull-downs, F2 user menu, Enter file-action menu) unfolds top-down from its frame; when the highlighted item changes, the new highlight row renders one frame in the hotkey accent before settling to the normal highlight style. - **Modal dialogs**: every modal dialog opens with a 100 ms "iris" — a growing centered window onto the finished dialog — instead of appearing fully formed. -- **Configuration**: `animations = true` (default) in `config.toml` general options; `--noanimations` launch flag; flag wins. When disabled, every surface renders its final frame immediately — pixel-identical to today. +- **Configuration**: `animations = true` (default) as a top-level key in `config.toml`; `--noanimations` launch flag; flag wins. When disabled, every surface renders its final frame immediately — pixel-identical to today. - **No new theme roles**: all animation colors come from roles that already exist (`splash.frame`, `splash.version`, `menu.hotkey`, `screen.backdrop`), so user `themes/*.toml` files keep validating. - **Determinism**: animation progress is derived from `State::clock_ms`, so `insta` snapshot tests pin a clock and capture mid-animation frames; states built without config keep animations off, so existing snapshots are unchanged. @@ -30,6 +30,6 @@ The design doc (§4.10) says FileCommand "never uses spinners or animation glyph ## Impact - **Crates:** `filecommand-core` — new `anim` module (progress/easing helpers, duration constants); `opened_at_ms`/`selected_at_ms` stamps on `MenuState`, `UserMenuState`, `FileActionMenuState`; `State::animations` flag, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; reducer sets stamps from `state.clock_ms` where overlays open; `config.rs` `animations` key. `filecommand-tui` — `--noanimations` in `parse_launch_args`, `apply_config` wiring, `print_usage` line; `splash.rs`, `menubar.rs`, `user_menu.rs`, `file_action_menu.rs` interpolated rendering; a shared scratch-buffer "reveal" helper in `views/mod.rs` used by the dialog iris and the panel wipe; new snapshot tests. -- **Docs:** README flag table and `config.toml` key table gain `--noanimations` / `animations`. +- **Docs:** README flag table and `config.toml` key table (the `--help` usage block lists it in its OPTIONS list) gain `--noanimations` / `animations`. - **Depends on:** `startup-splash` (clock + hold), `pulldown-menus`, `user-menu`, `file-action-menu`, `responsive-layout` (overlay geometry), `theme-system` (role-only colors). The `help-flags` change's usage block gains the new flag. - **Out of scope:** animating loading feedback (listings, Info panel, drive select, progress counters); a reduced-motion/"subtle" intermediate level; a runtime Options toggle; sound; ASCII-art logo; per-theme animation overrides. diff --git a/openspec/changes/ui-animations/specs/ui-animations/spec.md b/openspec/changes/ui-animations/specs/ui-animations/spec.md index b759a42..65dbc8a 100644 --- a/openspec/changes/ui-animations/specs/ui-animations/spec.md +++ b/openspec/changes/ui-animations/specs/ui-animations/spec.md @@ -23,7 +23,7 @@ The system SHALL implement every animation as a pure function of the elapsed tim ### Requirement: Animations are disabled by config or launch flag -The system SHALL animate by default and SHALL render every surface in its final form immediately — identical to the pre-animation rendering — when `animations = false` is set in `config.toml` general options or the `--noanimations` launch flag is passed. When the flag and config disagree, `--noanimations` SHALL win. The `--noanimations` flag SHALL appear in the `--help` usage block's flag table. A `State` constructed without configuration (`State::empty`) SHALL have animations disabled so that existing snapshot and reducer tests are unaffected. +The system SHALL animate by default and SHALL render every surface in its final form immediately — identical to the pre-animation rendering — when `animations = false` is set as a top-level key in `config.toml` or the `--noanimations` launch flag is passed. When the flag and config disagree, `--noanimations` SHALL win. The `--noanimations` flag SHALL appear in the `--help` usage block's OPTIONS list. A `State` constructed without configuration (`State::empty`) SHALL have animations disabled so that existing snapshot and reducer tests are unaffected. #### Scenario: Default is animated @@ -43,7 +43,7 @@ The system SHALL animate by default and SHALL render every surface in its final #### Scenario: Flag is documented in usage - **WHEN** the user runs `filecommand --help` -- **THEN** the flag table includes `--noanimations` +- **THEN** the OPTIONS list includes `--noanimations` #### Scenario: States built without config do not animate @@ -116,7 +116,7 @@ The system SHALL, when the splash is replaced by the panels (minimum hold elapse ### Requirement: Loading feedback stays static -The system SHALL NOT animate loading or progress feedback: streaming directory listings, the `Reading… N` mini-status, Info-panel `…` placeholders, drive-select volume-label fill-in, and the progress dialog's counters and byte gauge SHALL continue to render as static text updated in place. Decorative chrome (splash, menus, dialogs) is the only animated surface; this scoping supersedes design doc §4.10's blanket "never uses animation" statement for decorative chrome only. +The system SHALL NOT animate loading or progress feedback: streaming directory listings, the `Reading… N` mini-status, Info-panel `…` placeholders, drive-select volume-label fill-in, and the progress dialog's counters and byte gauge SHALL continue to render as static text updated in place. Decorative chrome (splash, menus, dialogs) is the only animated surface; this scoping supersedes design doc §4.10's blanket "never uses spinners or animation glyphs" statement for decorative chrome only. #### Scenario: Streaming listing has no spinner diff --git a/openspec/changes/ui-animations/tasks.md b/openspec/changes/ui-animations/tasks.md index 40e61d1..77a0a02 100644 --- a/openspec/changes/ui-animations/tasks.md +++ b/openspec/changes/ui-animations/tasks.md @@ -5,12 +5,12 @@ - [ ] 1.1 Add `filecommand-core::anim` with per-mille `progress`, `reveal_count`, `sweep_pos`, iris-rect helper, and the named duration constants from design D6 (ui-animations: "Render-only motion model driven by the reducer clock") - [ ] 1.2 Add `State::animations`, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; `State::empty` sets `animations = false` (ui-animations: "Animations are disabled by config or launch flag") - [ ] 1.3 Stamp `MenuState` (`opened_at_ms`, `pulldown_opened_at_ms`, `selected_at_ms`), `UserMenuState` and `FileActionMenuState` (`opened_at_ms`, `selected_at_ms`) from `state.clock_ms` in the reducer on open, traversal, and selection change (ui-animations: "Pop-up menus unfold and highlights settle") -- [ ] 1.4 Stamp `dialog_opened_at_ms` where each modal dialog becomes visible and `quit_opened_at_ms` where `quit_confirm` is set; stamp `panels_revealed_at_ms` on the Splash → Panels transition only (ui-animations: "Modal dialogs open with an iris"; "Panels wipe in after the splash") +- [ ] 1.4 Stamp `dialog_opened_at_ms` where each modal dialog becomes visible (including the About dialog and the startup warning, stamped on the first `Tick` when set before it) and `quit_opened_at_ms` where `quit_confirm` is set; stamp `panels_revealed_at_ms` on the Splash → Panels transition only (ui-animations: "Modal dialogs open with an iris"; "Panels wipe in after the splash") ## 2. Configuration and launch flag - [ ] 2.1 Parse `animations = ` in `config::parse` (general options; default `true`) (ui-animations: "Animations are disabled by config or launch flag") -- [ ] 2.2 Parse `--noanimations` in `parse_launch_args`; flag overrides config in `run`/`apply_config` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 2.2 Parse `--noanimations` in `parse_launch_args`; flag overrides config in `run` (alongside the existing `config.splash && !launch.no_splash` computation, since `apply_config` takes no launch options) (ui-animations: "Animations are disabled by config or launch flag") - [ ] 2.3 Add `--noanimations` to `main.rs` `print_usage` and to the README flag table; add `animations` to the README config key table (ui-animations: "Animations are disabled by config or launch flag") ## 3. Splash and panel reveal From 8d73efd657b34b5bfebdbaf0d1f042c8895e39e9 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:25:24 -0600 Subject: [PATCH 11/13] Archive help-flags, stale-tab-refresh-skips-history, delete-builtins-reject-dot-targets All three are fully implemented, so their spec deltas are synced into application-shell, panel-tabs, and command-line and the changes move to the archive. delete-key-triggers-delete stays open until its manual check (task 3.3) is done. Co-Authored-By: Claude Sonnet 5.5 --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/command-line/spec.md | 0 .../tasks.md | 0 .../2026-10-09-help-flags}/.openspec.yaml | 0 .../2026-10-09-help-flags}/design.md | 0 .../2026-10-09-help-flags}/proposal.md | 0 .../specs/application-shell/spec.md | 0 .../2026-10-09-help-flags}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/panel-tabs/spec.md | 0 .../tasks.md | 0 openspec/specs/application-shell/spec.md | 34 +++++++++++++++++++ openspec/specs/command-line/spec.md | 6 +++- openspec/specs/panel-tabs/spec.md | 6 +++- 18 files changed, 44 insertions(+), 2 deletions(-) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/.openspec.yaml (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/design.md (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/proposal.md (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/specs/command-line/spec.md (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/tasks.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/.openspec.yaml (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/design.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/proposal.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/specs/application-shell/spec.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/tasks.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/.openspec.yaml (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/design.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/proposal.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/specs/panel-tabs/spec.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/tasks.md (100%) diff --git a/openspec/changes/delete-builtins-reject-dot-targets/.openspec.yaml b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/.openspec.yaml similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/.openspec.yaml rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/.openspec.yaml diff --git a/openspec/changes/delete-builtins-reject-dot-targets/design.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/design.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/design.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/design.md diff --git a/openspec/changes/delete-builtins-reject-dot-targets/proposal.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/proposal.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/proposal.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/proposal.md diff --git a/openspec/changes/delete-builtins-reject-dot-targets/specs/command-line/spec.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/specs/command-line/spec.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/specs/command-line/spec.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/specs/command-line/spec.md diff --git a/openspec/changes/delete-builtins-reject-dot-targets/tasks.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/tasks.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/tasks.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/tasks.md diff --git a/openspec/changes/help-flags/.openspec.yaml b/openspec/changes/archive/2026-10-09-help-flags/.openspec.yaml similarity index 100% rename from openspec/changes/help-flags/.openspec.yaml rename to openspec/changes/archive/2026-10-09-help-flags/.openspec.yaml diff --git a/openspec/changes/help-flags/design.md b/openspec/changes/archive/2026-10-09-help-flags/design.md similarity index 100% rename from openspec/changes/help-flags/design.md rename to openspec/changes/archive/2026-10-09-help-flags/design.md diff --git a/openspec/changes/help-flags/proposal.md b/openspec/changes/archive/2026-10-09-help-flags/proposal.md similarity index 100% rename from openspec/changes/help-flags/proposal.md rename to openspec/changes/archive/2026-10-09-help-flags/proposal.md diff --git a/openspec/changes/help-flags/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-09-help-flags/specs/application-shell/spec.md similarity index 100% rename from openspec/changes/help-flags/specs/application-shell/spec.md rename to openspec/changes/archive/2026-10-09-help-flags/specs/application-shell/spec.md diff --git a/openspec/changes/help-flags/tasks.md b/openspec/changes/archive/2026-10-09-help-flags/tasks.md similarity index 100% rename from openspec/changes/help-flags/tasks.md rename to openspec/changes/archive/2026-10-09-help-flags/tasks.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/.openspec.yaml b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/.openspec.yaml similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/.openspec.yaml rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/.openspec.yaml diff --git a/openspec/changes/stale-tab-refresh-skips-history/design.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/design.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/design.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/design.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/proposal.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/proposal.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/proposal.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/proposal.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/specs/panel-tabs/spec.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/specs/panel-tabs/spec.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/specs/panel-tabs/spec.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/specs/panel-tabs/spec.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/tasks.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/tasks.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/tasks.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/tasks.md diff --git a/openspec/specs/application-shell/spec.md b/openspec/specs/application-shell/spec.md index 0eb21be..03ced60 100644 --- a/openspec/specs/application-shell/spec.md +++ b/openspec/specs/application-shell/spec.md @@ -181,3 +181,37 @@ The system SHALL open a quit-confirmation dialog — never exit directly — whe - **WHEN** a copy/move/delete job is running and the user requests quit via Esc, then confirms - **THEN** the job is aborted through the same path as its cancel action before the application exits +### Requirement: Help flags print usage and exit before TUI startup + +The system SHALL recognise `-h`, `-?`, and `--help` as the sole command-line argument, print a usage block to stdout, and exit with code 0 without acquiring the terminal, alternate screen, raw mode, or mouse capture. The usage block SHALL include: the binary name and version (sourced from `CARGO_PKG_VERSION`), a one-line description, a synopsis line, a flag table listing `-h` / `-?` / `--help`, `--theme `, `--nomouse`, and `--nosplash`, and a line directing the user to press F1 inside the application for the full key reference. + +#### Scenario: -h prints usage to stdout and exits 0 + +- **WHEN** the user runs `filecommand -h` +- **THEN** the usage block is written to stdout, the process exits with code 0, and the terminal is never placed into raw mode or the alternate screen + +#### Scenario: -? prints usage to stdout and exits 0 + +- **WHEN** the user runs `filecommand -?` +- **THEN** the usage block is written to stdout and the process exits with code 0 + +#### Scenario: --help prints usage to stdout and exits 0 + +- **WHEN** the user runs `filecommand --help` +- **THEN** the usage block is written to stdout and the process exits with code 0 + +#### Scenario: Usage block includes version and flag table + +- **WHEN** the usage block is printed +- **THEN** it includes the application name, the current version string, a synopsis, entries for `-h` / `-?` / `--help`, `--theme`, `--nomouse`, and `--nosplash`, and a note to press F1 for the full in-app key reference + +#### Scenario: Help flag with additional arguments still prints usage + +- **WHEN** the user runs `filecommand --help --theme nc-classic` +- **THEN** the usage block is printed and the process exits 0 without launching the TUI + +#### Scenario: No help flag — TUI starts normally + +- **WHEN** the application is invoked without `-h`, `-?`, or `--help` +- **THEN** no usage text is printed and the TUI starts as normal + diff --git a/openspec/specs/command-line/spec.md b/openspec/specs/command-line/spec.md index 731fe94..f745619 100644 --- a/openspec/specs/command-line/spec.md +++ b/openspec/specs/command-line/spec.md @@ -138,7 +138,7 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA ### Requirement: del and rmdir route into the existing delete-confirmation flow -`del ` and `rmdir ` SHALL resolve `` against the active panel's current directory. `del` SHALL only accept a target that is a file; `rmdir` SHALL only accept a target that is a directory. A target that doesn't exist, or whose type doesn't match the verb, SHALL be rejected: the panel's error state SHALL be set accordingly and no dialog SHALL open. A valid, type-matched target SHALL open the same delete-confirmation dialog used by F8 and the file-action menu's Delete entry, scoped to that single entry — including the existing non-empty-directory second confirmation for `rmdir` — and SHALL NOT delete anything until that dialog is accepted. +`del ` and `rmdir ` SHALL resolve `` against the active panel's current directory. `.` and `..` are never valid targets — neither names an entry deletable through these verbs — and SHALL be rejected outright: the panel's error state SHALL be set and no dialog SHALL open. `del` SHALL only accept a target that is a file; `rmdir` SHALL only accept a target that is a directory. A target that doesn't exist, or whose type doesn't match the verb, SHALL be rejected: the panel's error state SHALL be set accordingly and no dialog SHALL open. A valid, type-matched target SHALL open the same delete-confirmation dialog used by F8 and the file-action menu's Delete entry, scoped to that single entry — including the existing non-empty-directory second confirmation for `rmdir` — and SHALL NOT delete anything until that dialog is accepted. #### Scenario: del on a file opens the delete-confirmation dialog - **WHEN** the active panel is at `C:\NORTON`, `notes.txt` is a file in it, and the command buffer is `del notes.txt` @@ -160,3 +160,7 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA - **WHEN** the command buffer is `del \NOSUCHFILE.TXT` - **THEN** the command is rejected, no dialog opens, and the panel shows an error indicating the target was not found +#### Scenario: del/rmdir reject the current and parent directories +- **WHEN** the command buffer is `rmdir ..` (or `del ..`, `rmdir .`, `del .`) +- **THEN** the command is rejected, no dialog opens, and the panel shows an error indicating the target is invalid — the dialog must never offer to delete the panel's current or parent directory + diff --git a/openspec/specs/panel-tabs/spec.md b/openspec/specs/panel-tabs/spec.md index a6e1fbe..a00cbec 100644 --- a/openspec/specs/panel-tabs/spec.md +++ b/openspec/specs/panel-tabs/spec.md @@ -108,7 +108,7 @@ When the tabs do not all fit on the strip width, labels SHALL shrink stepwise ### Requirement: Stale background tab refresh on activation -A tab marked stale by a completed file-operation job (see `file-operations` — "Automatic panel re-read on completion") SHALL be refreshed with a fresh directory read the moment it becomes the active tab — via Alt+1..9 switch, or via the neighbor activation that follows a Ctrl+W close — instead of displaying its previously cached entries. A tab that is not marked stale SHALL continue to activate from its cached state with no re-read, exactly as today. +A tab marked stale by a completed file-operation job (see `file-operations` — "Automatic panel re-read on completion") SHALL be refreshed with a fresh directory read the moment it becomes the active tab — via Alt+1..9 switch, or via the neighbor activation that follows a Ctrl+W close — instead of displaying its previously cached entries. The deferred refresh is not a navigation: it SHALL re-read the directory but SHALL NOT record a fuzzy-jump history visit for it or persist configuration. A tab that is not marked stale SHALL continue to activate from its cached state with no re-read, exactly as today. #### Scenario: Switching to a stale background tab triggers a fresh read - **WHEN** a background tab is browsing a directory affected by a completed job while it was inactive, and the user activates it with Alt+`n` @@ -122,3 +122,7 @@ A tab marked stale by a completed file-operation job (see `file-operations` — - **WHEN** the user switches to a tab that was not affected by any completed job since it was last active - **THEN** the tab activates from its cached state with no re-read, as before +#### Scenario: The deferred refresh is not a navigation +- **WHEN** a stale background tab is activated and re-reads its directory +- **THEN** the directory is not added to or re-ranked in the fuzzy-jump history, and no configuration file is persisted as a result of the refresh + From 21190fb9e12ae2c31a50ef7d10e33ee65faba7e8 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:28:41 -0600 Subject: [PATCH 12/13] Archive delete-key-triggers-delete The manual Delete-key check (task 3.3) is confirmed done and validation is clean. The spec delta was already present in operation-dialogs, so the archive only moves the change. Co-Authored-By: Claude Sonnet 5.5 --- .../2026-10-09-delete-key-triggers-delete}/.openspec.yaml | 0 .../2026-10-09-delete-key-triggers-delete}/design.md | 0 .../2026-10-09-delete-key-triggers-delete}/proposal.md | 0 .../specs/operation-dialogs/spec.md | 0 .../2026-10-09-delete-key-triggers-delete}/tasks.md | 4 ++-- 5 files changed, 2 insertions(+), 2 deletions(-) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/.openspec.yaml (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/design.md (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/proposal.md (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/specs/operation-dialogs/spec.md (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/tasks.md (87%) diff --git a/openspec/changes/delete-key-triggers-delete/.openspec.yaml b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/.openspec.yaml similarity index 100% rename from openspec/changes/delete-key-triggers-delete/.openspec.yaml rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/.openspec.yaml diff --git a/openspec/changes/delete-key-triggers-delete/design.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/design.md similarity index 100% rename from openspec/changes/delete-key-triggers-delete/design.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/design.md diff --git a/openspec/changes/delete-key-triggers-delete/proposal.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/proposal.md similarity index 100% rename from openspec/changes/delete-key-triggers-delete/proposal.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/proposal.md diff --git a/openspec/changes/delete-key-triggers-delete/specs/operation-dialogs/spec.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/specs/operation-dialogs/spec.md similarity index 100% rename from openspec/changes/delete-key-triggers-delete/specs/operation-dialogs/spec.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/specs/operation-dialogs/spec.md diff --git a/openspec/changes/delete-key-triggers-delete/tasks.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/tasks.md similarity index 87% rename from openspec/changes/delete-key-triggers-delete/tasks.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/tasks.md index 1f28858..8ae0e77 100644 --- a/openspec/changes/delete-key-triggers-delete/tasks.md +++ b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/tasks.md @@ -10,5 +10,5 @@ ## 3. Verify - [x] 3.1 Run `cargo test -p filecommand-tui` (and the workspace test suite) — all green. -- [ ] 3.2 Run `openspec validate delete-key-triggers-delete --strict` — clean. -- [ ] 3.3 Manually confirm in the running app: cursor on a file, press Delete → the delete-confirmation dialog appears; accept it → the file is deleted; press Delete then Esc → nothing is deleted. +- [x] 3.2 Run `openspec validate delete-key-triggers-delete --strict` — clean. +- [x] 3.3 Manually confirm in the running app: cursor on a file, press Delete → the delete-confirmation dialog appears; accept it → the file is deleted; press Delete then Esc → nothing is deleted. From 99aca5f5289160f6af27672172de74e502460224 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski Date: Thu, 22 Oct 2026 11:32:18 -0600 Subject: [PATCH 13/13] Add OpenSpec proposal auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks each visible panel's directory every refresh_interval seconds (default 60, configurable, 0 = off) on a worker thread by comparing a content digest of its entries with the listing on screen, and re-reads the panel in place — cursor, scroll, selection, and sort kept, no history visit — only when the directory actually changed; a no-change check draws nothing. Adds F12 as a second binding for Ctrl+R's re-read and an eleventh `12Refresh` slot on the F-key bar (77/56/23-column forms). Deltas touch responsive-layout, sort-modes, mouse-input, application-shell, panel-navigation, and configuration-files. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/auto-refresh/.openspec.yaml | 2 + openspec/changes/auto-refresh/design.md | 51 +++++++ openspec/changes/auto-refresh/proposal.md | 48 +++++++ .../specs/application-shell/spec.md | 45 +++++++ .../auto-refresh/specs/auto-refresh/spec.md | 125 ++++++++++++++++++ .../specs/configuration-files/spec.md | 93 +++++++++++++ .../auto-refresh/specs/mouse-input/spec.md | 25 ++++ .../specs/panel-navigation/spec.md | 50 +++++++ .../specs/responsive-layout/spec.md | 20 +++ .../auto-refresh/specs/sort-modes/spec.md | 25 ++++ openspec/changes/auto-refresh/tasks.md | 47 +++++++ 11 files changed, 531 insertions(+) create mode 100644 openspec/changes/auto-refresh/.openspec.yaml create mode 100644 openspec/changes/auto-refresh/design.md create mode 100644 openspec/changes/auto-refresh/proposal.md create mode 100644 openspec/changes/auto-refresh/specs/application-shell/spec.md create mode 100644 openspec/changes/auto-refresh/specs/auto-refresh/spec.md create mode 100644 openspec/changes/auto-refresh/specs/configuration-files/spec.md create mode 100644 openspec/changes/auto-refresh/specs/mouse-input/spec.md create mode 100644 openspec/changes/auto-refresh/specs/panel-navigation/spec.md create mode 100644 openspec/changes/auto-refresh/specs/responsive-layout/spec.md create mode 100644 openspec/changes/auto-refresh/specs/sort-modes/spec.md create mode 100644 openspec/changes/auto-refresh/tasks.md diff --git a/openspec/changes/auto-refresh/.openspec.yaml b/openspec/changes/auto-refresh/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/auto-refresh/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/auto-refresh/design.md b/openspec/changes/auto-refresh/design.md new file mode 100644 index 0000000..fc8a628 --- /dev/null +++ b/openspec/changes/auto-refresh/design.md @@ -0,0 +1,51 @@ +## Context + +See proposal.md — Why. Panels re-read on their own only after FileCommand's jobs, on Ctrl+R / menu Re-read (`Command::RereadPanel` → `begin_listing`), on return from an external editor, and when a stale tab is activated (`refresh_activated_stale_tab` → `begin_listing_inner`). The design doc explicitly left filesystem watching out of v1. The reducer is pure (application-shell "The reducer performs no filesystem I/O"): every read happens on a worker thread and comes back as a `Command`; async replies carry request ids and stale ones are dropped (`listing_request`, `info_request`, `git_request`). The event loop is idle-aware: it wakes only for input, worker results, or a deadline from `anim::next_wake_ms` and the header clock's minute flip, and redraws only when something was applied (application-shell "Idle event loop does not redraw"). + +The F-key bar is a ladder of complete forms chosen by width (`views/keybar.rs`: 67/50/20 columns); clicks map slot numbers through `keybar_command` to the same commands as the keys. + +## Goals / Non-Goals + +**Goals:** +- A panel showing a directory that changed on disk re-reads within about one interval without user action, keeping the user's place. +- Zero cost when nothing changed: no state change, no redraw, no flicker. +- Never interfere with an interaction in progress — in particular never move the cursor under an open confirmation dialog. +- A visible, clickable `12Refresh` slot equivalent to Ctrl+R. +- Interval configurable; `0` disables. + +**Non-Goals:** +- Real-time change notification (a watcher). Polling at a minute granularity is deliberate: it needs no platform API, no extra thread per directory, and no handling of event storms. +- Probing background tabs, Tree/Info/QuickView panels, or the viewer/editor. +- Changing Ctrl+R's navigation bookkeeping (it records a frecency visit today; F12 mirrors it exactly). +- A `--norefresh` launch flag; F11; Ctrl/Alt+F12. + +## Decisions + +**D1 — Detect by content digest, not directory mtime.** On NTFS a directory's own modified time changes when children are added, removed, or renamed, but not when a child's size or content changes; on network shares and FAT it is less reliable still. The probe therefore enumerates the directory with the same reader and classification the listing uses (`StdFsReader`, `classify_entry`) and hashes the entries sorted by name: `(name, kind, size, modified, hidden, link)` plus the count. The reducer keeps the digest of the listing it shows (`PanelState::content_digest`, computed once at `ListingComplete` from `entries` minus `..`, cleared by `begin_new_listing`), so comparing is a `u64` equality — no per-minute O(n) work on the UI thread. Both sides call one `listing::content_digest` so the two digests are comparable by construction; entries the listing skipped as unreadable are skipped identically by the probe. *Alternative considered:* a directory-mtime probe — rejected because it misses in-place modifications, which are exactly what a build or a log writer produces. + +**D2 — Scheduling lives in the reducer clock.** `State::next_probe_ms` is set from `clock_ms + refresh_interval_ms` at startup and after every probe round. `Command::Tick` with `clock_ms >= next_probe_ms` emits `Effect::ProbeDirectory` for each eligible panel (D3), records `probe_request`, and advances `next_probe_ms`. `anim::next_wake_ms` returns `next_probe_ms` as a fourth deadline when `refresh_interval_ms > 0` and at least one panel is eligible, so the idle loop wakes once a minute for it and never otherwise. With `refresh_interval = 0` no deadline exists and nothing changes from today. A suspended session (external editor, Ctrl+O) resumes with a clock jump, so a probe falls due immediately on return — harmless, since the editor return already re-reads the active panel. *Alternative considered:* a TUI-side timer thread — rejected because it would bypass the reducer's single clock and make the behavior untestable in the reducer tests. + +**D3 — Eligibility is checked when a probe is issued and again when its result arrives.** A probe is issued for a panel only when: `phase == UiPhase::Panels`; `overlay.is_none()`; `drag.is_none()`; `quick_search.is_none()`; the panel's `progress` is `Complete` and `last_error.is_none()`; `display_mode` is `Full` or `Brief`; `probe_request.is_none()`. A `DirectoryProbed` is applied only if its `request` matches the panel's `probe_request` and its `path` equals the panel's `cwd`, and every condition above still holds (the request is cleared either way). Re-checking at arrival is what makes the F8 case safe: if a delete confirmation opened between probe and reply, the reply is dropped rather than re-reading under the dialog and letting the cursor land on a different file. The `probe_request.is_none()` guard means a slow share never accumulates probes; the next round simply waits. + +**D4 — A changed directory re-reads through a purpose-named in-place helper.** `begin_listing_inner`'s doc comment says a third non-navigation caller should prompt extracting a helper. `reread_in_place(state, side)` becomes that helper: `begin_listing_inner(side, cwd, capture_anchor = !streaming)` — exactly what `refresh_activated_stale_tab` does today, which now calls it too. The automatic refresh is therefore not a navigation: no `record_visit`, no `PersistHistory`; it does capture the re-read anchor (panel-navigation "Same-directory re-read preserves cursor and scroll position"), keeps the selection set (keyed by identity), keeps the sort mode, and re-issues the git-info query as every re-read does. F12 and the key-bar click, by contrast, dispatch `Command::RereadPanel(active)` and so behave byte-for-byte like Ctrl+R, including its frecency visit. *Alternative considered:* having the probe carry the fresh entries and apply them directly — rejected because it would duplicate the streaming listing's anchor-restore and request-guard logic for a second entry path. + +**D5 — An unchanged probe is invisible.** The reducer applies a matching no-change `DirectoryProbed` by clearing `probe_request` and returning no effects. The TUI's worker-result path marks the frame dirty for every applied command today; it is amended so a `DirectoryProbed` marks it dirty only when the reducer returned at least one effect (i.e. a `StartListing` round began). This keeps application-shell's "Idle screen is not redrawn" true between probes and extends it across a no-change probe. + +**D6 — Probe failure is silent.** `digest: None` (directory unreadable or gone) clears `probe_request` and nothing else. Flipping the panel into the inline read-error state on a timer would be startling and, for a transient share hiccup, wrong; the user's next navigation or Ctrl+R/F12 surfaces the real error through the existing path (filesystem-error-handling "Inline panel read-error state offering re-read or drive change"). + +**D7 — F12 as the eleventh slot; F11 skipped.** NC 5.5's bar had ten slots; F12 reads naturally as "the key past Quit" and is unassigned in every terminal we target. The forms become `1Help … 10Quit 12Refresh` (77 columns — fits the 80-column nominal width), `1Hlp … 10Qit 12Rfr` (56 — fits the 60-column floor), and `1 … 10 12` (23). `hit_slots` emits slot 12 and `keybar_command(12)` returns `RereadPanel(active)`. Drawing an empty `11` slot would waste five columns and invite the question of what F11 does; it is left out. + +**D8 — `refresh_interval` is a top-level integer key.** Parsed next to `panel_split`; accepted values are non-negative integers (seconds). `0` disables. A non-integer or negative value produces the standard line-numbered warning and the default 60 applies, matching how `splash`/`animations` report uninterpretable values. No upper clamp: a user who wants a 10-minute interval can have it. + +## Risks / Trade-offs + +- [Enumeration cost on huge or slow directories] → A probe reads the whole directory once per interval per panel — the same I/O as a non-streaming listing — on a worker thread; the UI thread does a `u64` compare. While the worker runs the loop wakes on its 33 ms cadence as it does for any worker (existing behavior). One outstanding probe per panel (D3); the interval is configurable and can be turned off. +- [A re-read is briefly visible] → The changed panel shows `Reading… N` while it streams, as Ctrl+R does. Acceptable: it only happens when the directory really changed. +- [Digest collision] → A 64-bit hash of the entry tuples could in theory collide, hiding a change until the next real difference. Negligible for a convenience refresh. +- [Clock semantics] → The probe timer runs on the reducer's monotonic clock; a laptop sleep makes one probe fall due on wake. Harmless. +- [Terminal F12 interception] → Windows Terminal, conhost, and common Linux terminals pass F12 through; some IDE-hosted terminals may bind it. Ctrl+R remains, so nothing is lost. +- [Key-bar snapshots] → Every `matrix_keybar_{w}` and panel snapshot that includes the bar changes; they are regenerated as part of the work, which is the cost of any bar change. + +## Open Questions + +None blocking. A later change could surface a one-shot mini-status note ("Refreshed") after an automatic re-read; this proposal keeps it silent, as NC did. diff --git a/openspec/changes/auto-refresh/proposal.md b/openspec/changes/auto-refresh/proposal.md new file mode 100644 index 0000000..53f89c0 --- /dev/null +++ b/openspec/changes/auto-refresh/proposal.md @@ -0,0 +1,48 @@ +## Why + +A panel only re-reads after FileCommand's own operations, on Ctrl+R or the Left/Right menu's Re-read item, or when a stale tab is activated. Anything another program does to the directory on screen — a compiler writing output, a download finishing, a file deleted from another shell — is invisible until the user remembers to press Ctrl+R, a chord that is not shown anywhere on screen. The design doc deferred filesystem watching to v2 and made Ctrl+R the only manual refresh (design doc, "Refresh policy"). This change adds a cheap periodic check that re-reads a panel only when its directory actually changed, and puts a visible `12Refresh` slot on the F-key bar so manual refresh is discoverable. + +## What Changes + +- Every `refresh_interval` seconds (default 60) a worker thread enumerates each eligible panel's current directory and reports a content digest of its direct entries (name, kind, size, modified time, hidden/system flag, link flag). The reducer compares it with the digest of the listing on screen: identical means nothing happens — no state change, no redraw; different means the panel re-reads in place. +- The automatic re-read is the same in-place refresh a stale tab gets on activation: cursor and scroll position are restored, the selection (keyed by entry identity) survives, the sort mode is kept, git info is re-queried, and no fuzzy-jump history visit or configuration write results. +- A probe is only issued — and its result only applied — while the panels are the active screen with no dialog, menu, drag, or type-ahead in progress, and only for a panel whose listing is complete, error-free, and shown in a list display mode (Full or Brief). A probe whose directory cannot be read changes nothing. At most one probe per panel is outstanding at a time. +- `refresh_interval` is a new top-level `config.toml` setting: a non-negative integer number of seconds; `0` turns automatic refresh off; a value that cannot be interpreted produces the standard startup warning and the default (60) applies. +- **F12** re-reads the active panel exactly as Ctrl+R does. The main F-key bar gains an eleventh slot: full form `… 10Quit 12Refresh` (77 columns), short form `… 10Qit 12Rfr` (56 columns), numbers-only `… 10 12` (23 columns). A click on the new slot dispatches the same re-read. Slot 11 is not drawn (F11 stays unassigned). The viewer and editor F-key bars are unchanged. +- The idle event loop treats the next probe as a fourth time-driven wake, and a probe that finds no change does not redraw the screen. +- Non-goals: a filesystem watcher (`notify` / `ReadDirectoryChangesW`); scanning background tabs (they keep the stale-on-activation path); refreshing Tree, Info, or QuickView panels; a launch flag for the interval; any change to Ctrl+R's own behavior. + +## Capabilities + +### New Capabilities + +- `auto-refresh`: periodic directory change detection, the in-place refresh it triggers, the conditions under which it stays out of the way, and the `refresh_interval` setting. + +### Modified Capabilities + +- `responsive-layout`: MODIFIED "F-key bar degradation forms" — the main bar has eleven slots; new form strings and widths. +- `sort-modes`: MODIFIED "Re-read the panel" — F12 is a second binding for the same re-read. +- `mouse-input`: MODIFIED "Key bar, menu bar, pull-down items, and dialog buttons are clickable" — adds the `12Refresh` click scenario. +- `application-shell`: MODIFIED "Idle event loop does not redraw" — the refresh probe is a time-driven wake; a no-change probe result does not redraw. +- `panel-navigation`: MODIFIED "Same-directory re-read preserves cursor and scroll position" — an automatic refresh is one of the re-reads that restore the user's place. +- `configuration-files`: MODIFIED "Malformed or unrecognized configuration lines produce startup warnings" — `refresh_interval` joins the keys whose uninterpretable value is reported. + +## Impact + +- **Core (`crates/filecommand-core`):** + - `update/command.rs` — `Command::DirectoryProbed { panel, path, request, digest: Option }`; `Effect::ProbeDirectory { panel, path, request }`. + - `update.rs` / `anim.rs` — `State::refresh_interval_ms`, `State::next_probe_ms`; `Tick` issues due probes; `anim::next_wake_ms` gains the probe deadline. + - `update/listing.rs` — extract `reread_in_place(state, side)` from `refresh_activated_stale_tab` (the "third caller" `begin_listing_inner`'s doc comment anticipates) and use it for the automatic refresh; apply `DirectoryProbed`. + - `update/overlays.rs` — `keybar_command(12)`. + - `panel.rs` — `PanelState::probe_request: Option`, `PanelState::content_digest: Option` (set at `ListingComplete`, cleared by `begin_new_listing`). + - `listing.rs` — `content_digest(entries)` shared by the listing and the probe; a non-streaming `read_dir_digest` for the worker. + - `config.rs` — `refresh_interval` key, default 60, integer parsing with warning. + - Tests: `update/tests.rs`, `anim.rs`, `panel.rs`, `listing.rs`, `config.rs`. +- **TUI (`crates/filecommand-tui`):** + - `input/mod.rs` — `KeyCode::F(12)` → `Command::RereadPanel(active)` in the panel key map. + - `views/keybar.rs` — eleven-slot forms, widths 77/56/23, `hit_slots` slot 12; `tests/snapshot_matrix.rs` key-bar snapshots regenerate. + - `worker.rs` — `spawn_probe` (enumerate, digest, deliver `DirectoryProbed`). + - `app.rs` — run `Effect::ProbeDirectory`; a `DirectoryProbed` that produces no effects does not set the redraw flag. + - Tests: `input/tests.rs`, `input/mouse/tests.rs`, `tests/snapshot_matrix.rs`, `tests/integration_smoke.rs`. +- **Docs:** `README.md` key table gains `F12` next to Ctrl+R and documents the `refresh_interval` setting; the Help "Keyboard reference" topic text mentions F12. +- **No persisted-format or dependency changes.** `config.toml` gains one optional key. diff --git a/openspec/changes/auto-refresh/specs/application-shell/spec.md b/openspec/changes/auto-refresh/specs/application-shell/spec.md new file mode 100644 index 0000000..39ca0a0 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/application-shell/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Idle event loop does not redraw + +The system SHALL redraw the screen only when something visible may have changed: when input or a worker result that changed state has been applied, or when a time-driven change becomes due — a frame of a running animation, the expiry of clipboard feedback, the end of the splash hold, or a minute boundary of the header clock. The automatic directory check of `auto-refresh` is a time-driven wake that SHALL fall due only at its configured interval, and a check result that finds no change SHALL NOT cause a redraw. While none of these occurs the system SHALL NOT redraw, and SHALL NOT wake more often than about every 33 ms, and then only while worker threads that can still deliver results are running. Time-driven behavior SHALL keep its timing: animations SHALL still advance every frame from the reducer clock until they finish, clipboard feedback SHALL still expire on time without input, and the splash SHALL still end after its hold. Before input is applied after an idle period, the system SHALL bring the reducer clock up to date, so that a surface opened after idling animates from the moment it opened and two clicks separated by idle time are not treated as a double-click. A pending redraw SHALL NOT be lost when the input that follows it produces no command. + +#### Scenario: Idle screen is not redrawn + +- **WHEN** the panels are shown with no input arriving, no worker thread running, no animation in progress, no clipboard feedback shown, no automatic check falling due, and no minute boundary passing for 30 seconds +- **THEN** no frame is drawn during those 30 seconds + +#### Scenario: An unchanged directory check draws nothing + +- **WHEN** the panels sit idle across an automatic directory check whose directories have not changed +- **THEN** the only wake-ups are the check's issue and its result, and no frame is drawn for either + +#### Scenario: Header clock still advances while idle + +- **WHEN** the application is idle and the wall clock passes a minute boundary +- **THEN** the screen is redrawn once at that boundary and the header clock shows the new minute + +#### Scenario: Animations still run after idling + +- **WHEN** the application has been idle for a minute with animations enabled and the user presses F9 +- **THEN** the menu bar sweeps and the `Left` pull-down unfolds over their normal durations with intermediate frames, rather than appearing complete on the first frame + +#### Scenario: Clipboard feedback expires without input + +- **WHEN** clipboard feedback is shown and the user presses no key +- **THEN** the feedback disappears and the mini-status reverts when its expiry time is reached + +#### Scenario: Worker results still appear promptly + +- **WHEN** a directory listing is being read while the user presses no key +- **THEN** each batch of entries is shown within about 33 ms of arriving, and once the listing completes and no worker remains the screen stops redrawing + +#### Scenario: Clicks separated by idle time are not a double-click + +- **WHEN** the user clicks an entry, the application sits idle for 10 seconds, and the user clicks the same entry again +- **THEN** the second click is handled as a single click, not as a double-click + +#### Scenario: Redraw survives an unmapped key + +- **WHEN** a worker result is applied and, in the same loop iteration, the user presses a key that maps to no command +- **THEN** the screen is still redrawn to show the worker result diff --git a/openspec/changes/auto-refresh/specs/auto-refresh/spec.md b/openspec/changes/auto-refresh/specs/auto-refresh/spec.md new file mode 100644 index 0000000..2e5e1ae --- /dev/null +++ b/openspec/changes/auto-refresh/specs/auto-refresh/spec.md @@ -0,0 +1,125 @@ +## ADDED Requirements + +### Requirement: Periodic directory change detection + +While automatic refresh is enabled, the system SHALL check each eligible panel's current directory for changes every `refresh_interval` seconds (default 60) without user input, on a worker thread. An unchanged directory SHALL cause no state change and no redraw; a changed one SHALL re-read as "Automatic refresh is an in-place re-read" specifies. Both panels' active tabs are eligible; background tabs are not checked and keep their stale-on-activation refresh. + +#### Scenario: A file created by another program appears + +- **WHEN** the left panel shows `C:\build` with the cursor on `main.obj`, another program creates `C:\build\app.exe`, and the next automatic check runs +- **THEN** the left panel re-reads, lists `app.exe` in sorted position, and the cursor is still on `main.obj` with the scroll offset unchanged + +#### Scenario: Nothing changed, nothing happens + +- **WHEN** a panel's directory has not changed since its listing completed and an automatic check runs +- **THEN** the panel's entries, cursor, scroll offset, and selection are untouched, no `Reading…` indicator appears, and no frame is drawn as a result of the check + +#### Scenario: Both panels are checked + +- **WHEN** the left panel shows `C:\a` and the right panel shows `D:\b`, both listings are complete, and a check falls due +- **THEN** both directories are checked in the same round, and only a panel whose directory changed re-reads + +#### Scenario: Background tabs are not checked + +- **WHEN** the active panel has a background tab on `C:\logs` and another program writes a file there +- **THEN** no check is made for `C:\logs` while that tab is in the background, and the tab still re-reads on activation only if it was marked stale + +### Requirement: Change detection compares a content digest + +A check SHALL enumerate the directory's direct entries with the same reader and classification the panel listing uses, and SHALL compute a content digest over each entry's name, kind, size, modified time, hidden/system flag, and link flag together with the entry count. The digest SHALL be compared with the digest of the listing the panel shows; the directory counts as changed exactly when the two differ. A check whose directory cannot be read SHALL change nothing. + +#### Scenario: A file modified in place is noticed + +- **WHEN** a panel shows `notes.txt` with size 1,204 and another program appends to it so its size and modified time change, and the next automatic check runs +- **THEN** the panel re-reads and shows the new size and modified time for `notes.txt` + +#### Scenario: A rename is noticed + +- **WHEN** another program renames `draft.txt` to `final.txt` in a panel's directory and the next automatic check runs +- **THEN** the panel re-reads and lists `final.txt` in place of `draft.txt` + +#### Scenario: An unreadable directory is left alone + +- **WHEN** a panel shows a listing of `E:\photos`, the drive is removed, and an automatic check runs +- **THEN** the panel keeps showing its last listing with no error state, and the user's next navigation or Ctrl+R/F12 shows the normal inline read-error state + +### Requirement: One outstanding check per panel + +At most one check per panel SHALL be outstanding at any time. A check that has not answered when the next interval falls due SHALL delay that panel's next check until it has answered; the other panel's checks SHALL continue on schedule. + +#### Scenario: A slow share does not pile up checks + +- **WHEN** the left panel's directory is on a share whose enumeration takes longer than `refresh_interval` and the right panel shows a local directory +- **THEN** no second check for the left panel starts until the first has answered, and the right panel is still checked every interval + +### Requirement: Automatic refresh is an in-place re-read + +A re-read triggered by an automatic check SHALL be the in-place refresh a stale tab receives on activation, not a navigation: cursor and scroll position restored per panel-navigation "Same-directory re-read preserves cursor and scroll position", the identity-keyed selection kept for entries still present, sort mode and direction kept, and the git-info query re-issued. It SHALL NOT record a fuzzy-jump history visit or write any configuration file. + +#### Scenario: Selection survives an automatic refresh + +- **WHEN** `a.txt` and `c.txt` are selected in a panel, another program adds `b.txt`, and the next check re-reads the panel +- **THEN** `a.txt` and `c.txt` are still selected and `b.txt` is not + +#### Scenario: The refresh is not a navigation + +- **WHEN** an automatic check re-reads a panel +- **THEN** the directory's fuzzy-jump frecency entry is not created, re-ranked, or re-stamped, and `history.json` and `config.toml` are not written + +#### Scenario: Sort mode is kept + +- **WHEN** a panel sorted by Size descending is re-read by an automatic check +- **THEN** the refreshed listing is sorted by Size descending and the header still shows the arrow on the Size column + +### Requirement: Automatic refresh never disturbs an interaction + +A check SHALL be issued for a panel only while the panels are the active screen with no dialog, menu, mouse drag, or type-ahead in progress, and only for a panel whose listing is complete without a read error in Full or Brief display mode. A result SHALL be applied only if those conditions still hold on arrival, the panel still shows the checked directory, and the result is the panel's current outstanding check; otherwise it SHALL be dropped and the next interval tries again. + +#### Scenario: A confirmation dialog is never undermined + +- **WHEN** the delete confirmation for `report.txt` is open, another program deletes `report.txt`, and an automatic check falls due or its result arrives +- **THEN** the panel is not re-read while the dialog is open, the dialog still names `report.txt`, and confirming it reports that the file is missing through the normal error path rather than deleting a different file + +#### Scenario: Checks resume after the dialog closes + +- **WHEN** the dialog from the previous scenario is cancelled and the next interval elapses +- **THEN** the check runs, finds the directory changed, and the panel re-reads without `report.txt` + +#### Scenario: A streaming listing is not checked + +- **WHEN** a panel is still showing `Reading… N` for a large directory when a check falls due +- **THEN** no check is issued for that panel in that round + +#### Scenario: Tree, Info, and QuickView panels are skipped + +- **WHEN** the left panel is in Tree mode and the right panel in Full mode and a check falls due +- **THEN** only the right panel's directory is checked + +#### Scenario: Viewer, editor, and running jobs are never interrupted + +- **WHEN** the built-in viewer or editor is open, or a file-operation dialog or job is on screen, and an interval elapses +- **THEN** no check is issued and no panel re-reads until the panels are the active screen again + +### Requirement: Configurable refresh interval + +The system SHALL read `refresh_interval` from `config.toml`'s top-level settings as a non-negative integer number of seconds, defaulting to `60`. `0` SHALL disable automatic checks entirely, leaving the event loop with no refresh deadline. A value that is not a non-negative integer SHALL produce the standard startup warning naming the line, and the default SHALL apply. The interval SHALL be measured from when the previous round of checks was issued. + +#### Scenario: Default interval + +- **WHEN** `config.toml` has no `refresh_interval` line +- **THEN** checks run about every 60 seconds + +#### Scenario: Automatic refresh turned off + +- **WHEN** `config.toml` contains `refresh_interval = 0` +- **THEN** no check is ever issued, and an idle screen with no other deadline draws no frame for as long as it sits idle + +#### Scenario: Custom interval + +- **WHEN** `config.toml` contains `refresh_interval = 15` +- **THEN** checks run about every 15 seconds + +#### Scenario: Uninterpretable value is reported + +- **WHEN** `config.toml` contains `refresh_interval = fast` on line 6 +- **THEN** checks run at the default 60-second interval and the startup-warning dialog names `config.toml` line 6 and states that the value must be a non-negative whole number of seconds diff --git a/openspec/changes/auto-refresh/specs/configuration-files/spec.md b/openspec/changes/auto-refresh/specs/configuration-files/spec.md new file mode 100644 index 0000000..dec9f01 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/configuration-files/spec.md @@ -0,0 +1,93 @@ +## MODIFIED Requirements + +### Requirement: Malformed or unrecognized configuration lines produce startup warnings + +The system SHALL report each of the following `config.toml` lines as a warning naming the file and the line number, instead of ignoring it silently: + +- a line that is neither blank, a comment, a table header, nor `key = value`; +- a quoted value with no valid closing quote; +- an unrecognized key; +- a recognized key placed under a table where it does not belong, such as a `key.*` binding after `[mouse]`. The warning SHALL state that the setting was ignored because top-level settings must come before the first table; +- an unrecognized table header. One warning for the header SHALL cover the keys beneath it; +- a value of `splash`, `animations`, `refresh_interval`, `[mouse] enabled`, or a `key.*` binding that cannot be interpreted. The default for that key SHALL apply. + +A well-formed `theme` or `panel_split` line whose value is not a built-in theme name or a valid split percentage SHALL keep its existing silent fallback. A `config.toml` that exists but is not valid UTF-8 SHALL produce a warning, and default settings SHALL apply. A missing `config.toml` SHALL NOT produce a warning. + +The warnings that `usermenu.toml` parsing already produces SHALL be reported the same way: an unrecognized key in an entry, a key outside an `[[entry]]` block, and an entry missing `label` or `command`. A `usermenu.toml` that exists but is not valid UTF-8 SHALL be treated as malformed: the startup warning dialog SHALL be raised, the menu SHALL fall back to default entries, and the file SHALL NOT be overwritten. + +The warnings of both files SHALL be collected by the shared tokenizer and its parsers. They SHALL be shown once at startup through the dismissable startup-warning dialog as a single summary: the first warning, in file then line order, followed by the count of further warnings. This summary SHALL NOT replace any other startup warning raised in the same startup, such as a profile-directory warning, the `--theme` override warning, or the malformed-`usermenu.toml` warning; when one of those is raised, it SHALL be shown instead of the summary. When `usermenu.toml` is re-read after the user edits it from within FileCommand, its warnings SHALL be shown the same way. Problems in `history.json` SHALL NOT produce warnings. Warnings SHALL NOT prevent the application from launching. + +#### Scenario: Binding placed after the mouse table is reported + +- **WHEN** `config.toml` contains `[mouse]` on line 8, `enabled = true` on line 9, and `key.fuzzy_jump = "ctrl+g"` on line 11, and the application starts +- **THEN** Ctrl+J still opens the fuzzy jump dialog, and the startup-warning dialog names `config.toml` line 11 and states that `key.fuzzy_jump` is not a `[mouse]` setting and that top-level settings must come before the first table + +#### Scenario: Unknown key is reported + +- **WHEN** `config.toml` contains `colour = "red"` on line 4 together with `splash = false`, and the application starts +- **THEN** the splash is skipped, and the startup-warning dialog names `config.toml` line 4 and the unknown setting `colour` + +#### Scenario: Line without an equals sign is reported + +- **WHEN** `config.toml` contains the line `splash false` on line 2 +- **THEN** the line is ignored, the splash setting keeps its default, and the startup-warning dialog names `config.toml` line 2 + +#### Scenario: Uninterpretable boolean is reported + +- **WHEN** `config.toml` contains `animations = yes` +- **THEN** animations stay enabled (the default), and the startup-warning dialog names that line and states that the value must be `true` or `false` + +#### Scenario: Uninterpretable refresh interval is reported + +- **WHEN** `config.toml` contains `refresh_interval = -5` +- **THEN** automatic refresh runs at the default 60-second interval, and the startup-warning dialog names that line and states that the value must be a non-negative whole number of seconds + +#### Scenario: Several warnings are summarized + +- **WHEN** `config.toml` has three lines that produce warnings and the application starts +- **THEN** the startup-warning dialog shows the warning for the earliest of those lines followed by an indication that 2 more warnings exist + +#### Scenario: Clean configuration produces no warning + +- **WHEN** `config.toml` and `usermenu.toml` contain only recognized settings, comments, and blank lines +- **THEN** no startup-warning dialog is shown for configuration content + +#### Scenario: Unknown theme keeps its silent fallback + +- **WHEN** `config.toml` contains `theme = "no-such-theme"` +- **THEN** the active theme is `nc-classic` and no configuration warning is raised for that line + +#### Scenario: Unterminated quote is reported + +- **WHEN** `config.toml` contains `theme = "purple-lights` on line 3, with no closing quote +- **THEN** the active theme is `nc-classic`, and the startup-warning dialog names `config.toml` line 3 + +#### Scenario: Non-UTF-8 config.toml is reported + +- **WHEN** `config.toml` exists but contains a byte sequence that is not valid UTF-8, such as an ANSI-encoded `é` +- **THEN** the application starts with default settings and the startup-warning dialog states that `config.toml` is not UTF-8 text + +#### Scenario: Non-UTF-8 usermenu.toml is kept, not overwritten + +- **WHEN** `usermenu.toml` exists but is saved as UTF-16 or contains a byte sequence that is not valid UTF-8, and the application starts +- **THEN** the startup-warning dialog is shown, the F2 menu falls back to the default entries, and `usermenu.toml` on disk is byte-for-byte unchanged + +#### Scenario: Unknown user-menu key is shown + +- **WHEN** an `[[entry]]` in `usermenu.toml` has `label`, `command`, and an unrecognized key `icon` on line 5, and the application starts +- **THEN** the entry appears in the F2 menu, and the startup-warning dialog names `usermenu.toml` line 5 and the unknown key `icon` + +#### Scenario: Warnings after editing the user menu + +- **WHEN** the user edits `usermenu.toml` from within FileCommand, adds an entry with an unrecognized key, and returns to the panels +- **THEN** the edited entries replace the F2 menu, and the warning dialog names `usermenu.toml` and the line of the unrecognized key + +#### Scenario: Profile warning keeps precedence + +- **WHEN** the profile directory cannot be created at startup +- **THEN** the startup-warning dialog shows the unwritable-profile warning, not a configuration warning + +#### Scenario: Theme override warning keeps precedence + +- **WHEN** `config.toml` contains an unknown setting and the application is launched with `--theme no-such-theme` +- **THEN** the startup-warning dialog shows the `--theme` warning naming `no-such-theme`, not the configuration warning summary diff --git a/openspec/changes/auto-refresh/specs/mouse-input/spec.md b/openspec/changes/auto-refresh/specs/mouse-input/spec.md new file mode 100644 index 0000000..ce38640 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/mouse-input/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Key bar, menu bar, pull-down items, and dialog buttons are clickable + +A left-click on a function-key-bar slot SHALL dispatch the same command as that function key; a click on a menu-bar title SHALL open that pull-down; a click on an enabled pull-down item SHALL activate it; a click outside an open pull-down SHALL close it; a click on a dialog button SHALL activate that button. + +#### Scenario: Key bar Copy + +- **WHEN** the user clicks the `5Copy` slot +- **THEN** the destination-input dialog opens exactly as for F5 + +#### Scenario: Key bar Refresh + +- **WHEN** the user clicks the `12Refresh` slot (or `12Rfr` / `12` in a narrower form) +- **THEN** the active panel re-reads exactly as for F12 and Ctrl+R + +#### Scenario: Menu item activation + +- **WHEN** the user clicks `Files` in the menu bar and then clicks `Delete` +- **THEN** the delete-confirmation flow starts exactly as for F8 + +#### Scenario: Dialog button + +- **WHEN** the overwrite-conflict dialog is open and the user clicks `Skip All` +- **THEN** the choice is applied exactly as if selected by keyboard diff --git a/openspec/changes/auto-refresh/specs/panel-navigation/spec.md b/openspec/changes/auto-refresh/specs/panel-navigation/spec.md new file mode 100644 index 0000000..bc3bb35 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/panel-navigation/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: Same-directory re-read preserves cursor and scroll position + +When a panel re-reads the directory it is already displaying — after a file-operation job completes or is cancelled, on Ctrl+R, F12, or the Left/Right menu's Re-read item, on a stale background tab's activation refresh, or on an automatic refresh after a directory check found a change — the system SHALL restore the cursor to the entry it was on if that entry is still listed; otherwise to the first surviving entry at or after the cursor's previous row; otherwise to the nearest surviving entry before it. The scroll offset SHALL be restored to its previous value and then re-clamped so the window does not extend past the end of the new list and the cursor remains visible. The restored position SHALL hold while the re-read streams in — the cursor SHALL NOT transiently jump to the first entry — and after completion. Navigation into a different directory SHALL continue to reset the cursor and offset to the top. For same-directory re-reads this requirement takes precedence over "Streamed listing keeps the top pinned until the user moves". + +#### Scenario: Deleting the cursored file lands on the next entry + +- **WHEN** the cursor is on `b.txt` in a listing `a.txt`, `b.txt`, `c.txt` and the user confirms a delete of `b.txt` that completes +- **THEN** after the automatic re-read the cursor is on `c.txt` and the scroll offset is unchanged + +#### Scenario: Deleting the last entry lands on the previous one + +- **WHEN** the cursor is on the last entry of a listing and the user confirms a delete of that entry that completes +- **THEN** after the re-read the cursor is on the entry that preceded it, now the last entry + +#### Scenario: Multi-selection delete lands on the first survivor at or after the cursor + +- **WHEN** a listing `a`, `b`, `c`, `d`, `e` has `b`, `c`, `d` selected with the cursor on `c`, and a delete of the selection completes +- **THEN** after the re-read the cursor is on `e` + +#### Scenario: Scrolled window stays put + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 30 and the cursor on position 40, and a delete of the cursored entry completes +- **THEN** after the re-read the scroll offset is still 30 and the cursor is on position 40, which is the entry formerly at position 41 + +#### Scenario: Deleting at the very bottom pulls the window up by one + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 40 and the cursor on the last entry, and a delete of that entry completes +- **THEN** after the re-read the list has 59 entries, the scroll offset is re-clamped to 39, the cursor is on the new last entry, and no blank trailing row is shown + +#### Scenario: Ctrl+R keeps the cursor's entry + +- **WHEN** the cursor is on `report.txt` partway down a listing and the user presses Ctrl+R +- **THEN** after the re-read the cursor is on `report.txt` and the scroll offset is unchanged + +#### Scenario: Automatic refresh keeps the cursor's entry + +- **WHEN** the cursor is on `report.txt` partway down a listing and an automatic directory check finds that a file was added elsewhere in the directory +- **THEN** after the automatic re-read the cursor is on `report.txt` and the scroll offset is unchanged + +#### Scenario: No jump while the re-read streams + +- **WHEN** a same-directory re-read is in progress and its first streamed chunk contains the anchored entry +- **THEN** the cursor is on that entry as soon as the chunk is applied, not on the first entry + +#### Scenario: Navigating to a different directory still starts at the top + +- **WHEN** the cursor is partway down a listing and the user presses Enter on a subdirectory +- **THEN** the new listing starts with the cursor on its first entry and the scroll offset at 0 diff --git a/openspec/changes/auto-refresh/specs/responsive-layout/spec.md b/openspec/changes/auto-refresh/specs/responsive-layout/spec.md new file mode 100644 index 0000000..8b6d7e3 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/responsive-layout/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: F-key bar degradation forms + +The system SHALL render the F-key bar in the widest of three canonical forms that fits the terminal width, never truncating a slot mid-label: the full form `1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit 12Refresh` (77 columns), the short form `1Hlp 2Mnu 3Vew 4Edt 5Cpy 6Ren 7Mkd 8Del 9Pdn 10Qit 12Rfr` (56 columns), and the numbers-only form `1 2 3 4 5 6 7 8 9 10 12` (23 columns). All eleven slots SHALL always be present in whichever form renders; no slot numbered 11 is drawn. The same widest-form-that-fits rule SHALL govern the Ctrl and Alt modifier label variants and the viewer and editor F-key bars, each with its own short-form labels of the same shape; those bars gain no twelfth slot. + +#### Scenario: Full form at nominal width + +- **WHEN** the terminal is 80 columns wide +- **THEN** the key bar renders the full form with all eleven labeled slots, ending in `12Refresh` + +#### Scenario: Short form at the floor + +- **WHEN** the terminal is 60 columns wide +- **THEN** the key bar renders the short form `1Hlp 2Mnu 3Vew 4Edt 5Cpy 6Ren 7Mkd 8Del 9Pdn 10Qit 12Rfr` with all eleven slots visible + +#### Scenario: No mid-label truncation + +- **WHEN** the terminal width falls between two forms' required widths +- **THEN** the narrower form renders in full; no slot is ever partially drawn diff --git a/openspec/changes/auto-refresh/specs/sort-modes/spec.md b/openspec/changes/auto-refresh/specs/sort-modes/spec.md new file mode 100644 index 0000000..9fd6062 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/sort-modes/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Re-read the panel + +Ctrl+R and F12 SHALL each re-read the active panel's directory, replacing its entry list with a fresh streaming read while preserving the panel's current sort mode. The two keys SHALL dispatch the identical command, so everything specified for Ctrl+R anywhere holds for F12. The re-read MUST use the same streaming read path as the initial listing, so a large directory fills progressively and shows `Reading… N` in the mini-status until the read completes. + +#### Scenario: Ctrl+R re-reads the directory + +- **WHEN** Ctrl+R is pressed on a focused panel +- **THEN** the panel discards its current entries and begins a fresh streaming read of the same directory + +#### Scenario: F12 re-reads the directory + +- **WHEN** F12 is pressed on a focused panel +- **THEN** the panel re-reads exactly as it does for Ctrl+R, keeping the cursor's entry and scroll offset + +#### Scenario: Re-read preserves sort mode + +- **WHEN** the panel sort mode is Size and Ctrl+R is pressed +- **THEN** the freshly read entries are sorted by Size and the header still shows the sort arrow on the Size column + +#### Scenario: Re-read of a large directory streams with a running count + +- **WHEN** Ctrl+R is pressed on a directory large enough to stream in chunks +- **THEN** the mini-status shows `Reading… N` with N updating as chunks arrive, reverting to the normal mini-status display when the read completes diff --git a/openspec/changes/auto-refresh/tasks.md b/openspec/changes/auto-refresh/tasks.md new file mode 100644 index 0000000..40be1c5 --- /dev/null +++ b/openspec/changes/auto-refresh/tasks.md @@ -0,0 +1,47 @@ +## 1. Core: configuration + +- [ ] 1.1 Add `refresh_interval: u32` (seconds, default 60) to `Config` in `crates/filecommand-core/src/config.rs`; parse it as a non-negative integer next to `panel_split`; an uninterpretable value yields a line-numbered warning and the default (configuration-files "Malformed or unrecognized configuration lines produce startup warnings"). +- [ ] 1.2 Thread it into `State` as `refresh_interval_ms` (`0` = disabled); `State::empty` disables it so existing reducer and snapshot tests are unaffected. +- [ ] 1.3 Unit tests: default, `0`, a positive value, and the `refresh_interval = fast` / `-5` warnings. + +## 2. Core: content digest + +- [ ] 2.1 Add `listing::content_digest(entries)` hashing `(name, kind, size, modified, hidden, link)` over entries sorted by name, plus the count; the synthetic `..` is excluded. +- [ ] 2.2 Add `listing::read_dir_digest(reader, path, cancel) -> io::Result` — a non-streaming enumeration through the same `classify_entry` path, skipping unreadable entries exactly as the listing does. +- [ ] 2.3 `PanelState::content_digest: Option` — computed at `ListingComplete`, cleared by `begin_new_listing`; tests that identical entry sets in different arrival order digest equally and that a change to any hashed field differs. + +## 3. Core: probe scheduling and application + +- [ ] 3.1 Add `Command::DirectoryProbed { panel, path, request, digest: Option }` and `Effect::ProbeDirectory { panel, path, request }` in `update/command.rs`. +- [ ] 3.2 Add `State::next_probe_ms` and `PanelState::probe_request`; on `Tick`, when due and `refresh_interval_ms > 0`, issue a probe for each eligible panel (design D3) and advance `next_probe_ms`. +- [ ] 3.3 Extend `anim::next_wake_ms` with the probe deadline (only when enabled and some panel is eligible). +- [ ] 3.4 Extract `reread_in_place(state, side)` in `update/listing.rs` from `refresh_activated_stale_tab`; make the latter call it; update `begin_listing_inner`'s doc comment. +- [ ] 3.5 Apply `DirectoryProbed`: drop on request/path mismatch or ineligibility; `None` or an equal digest → clear `probe_request`, no effects; a differing digest → `reread_in_place`. +- [ ] 3.6 Reducer tests: a probe is issued once per interval for both eligible panels; not issued while an overlay, viewer, editor, file-op dialog/job, drag, or type-ahead is active, for a streaming or errored panel, or for Info/Tree/QuickView; one outstanding probe per panel; a result is dropped when a dialog opened meanwhile; a no-change result yields no effects; a changed result starts a listing with the anchor captured and emits no `PersistHistory`; `refresh_interval_ms == 0` never probes; `next_wake_ms` reports the deadline. + +## 4. Core: F12 slot command + +- [ ] 4.1 `keybar_command(12)` → `Command::RereadPanel(active)` in `update/overlays.rs` (resolve the active side at the call site or pass it in), with a test. + +## 5. TUI: key, bar, click + +- [ ] 5.1 `KeyCode::F(12)` → `Command::RereadPanel(active)` in `input/mod.rs`'s panel key map; test alongside the Ctrl+R test in `input/tests.rs`. +- [ ] 5.2 `views/keybar.rs`: add `("12", "Refresh")`, `("12", "Rfr")`, `("12", "")` to the three forms; update the width tests to 77/56/23; `hit_slots` yields slot 12; the drag relabel is unchanged. +- [ ] 5.3 Mouse test: a click on the `12Refresh` rect dispatches `KeybarPress(12)` and the reducer turns it into a re-read of the active panel. +- [ ] 5.4 Regenerate the `tests/snapshot_matrix.rs` key-bar snapshots and any panel snapshots that include the bar; confirm 60 columns renders `… 10Qit 12Rfr` in full and 66 columns still renders the short form. + +## 6. TUI: worker and event loop + +- [ ] 6.1 `worker::spawn_probe(panel, path, request, cancel, tx, token)` runs `read_dir_digest` and delivers `DirectoryProbed` (`digest: None` on error); it never delivers after cancellation. +- [ ] 6.2 `app.rs` runs `Effect::ProbeDirectory`; a `DirectoryProbed` that yields no effects does not set the redraw flag (design D5). +- [ ] 6.3 Integration test (`tests/integration_smoke.rs`): with a 1-second interval, create a file externally → the panel lists it after the next probe with the cursor on its previous entry; with nothing changed, no frame is drawn across a probe. + +## 7. Docs + +- [ ] 7.1 `README.md`: key table row `Ctrl+R / F12 | Re-read (refresh) the active panel`; the configuration section documents `refresh_interval` (seconds, default 60, `0` off) and the automatic refresh. +- [ ] 7.2 The Help "Keyboard reference" topic text mentions F12 next to Ctrl+R. + +## 8. Verification + +- [ ] 8.1 `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` all pass. +- [ ] 8.2 Manual: run at 80×24 and 60×16 and check the bar; press F12 and click `12Refresh`; set `refresh_interval = 5`, touch a file from another shell, see it appear with the cursor in place; open F8 on a file, delete it externally, wait a probe — the dialog's target does not change.