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.