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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/auto-refresh/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22
51 changes: 51 additions & 0 deletions openspec/changes/auto-refresh/design.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions openspec/changes/auto-refresh/proposal.md
Original file line number Diff line number Diff line change
@@ -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<u64> }`; `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<u64>`, `PanelState::content_digest: Option<u64>` (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.
45 changes: 45 additions & 0 deletions openspec/changes/auto-refresh/specs/application-shell/spec.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading